<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/scripts/pretty-feed-v3.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:h="http://www.w3.org/TR/html4/"><channel><title>Seeridia&apos;s Home</title><description>Seeridia 的小小小的大窝！</description><link>https://blog.seeridia.top</link><item><title>把散落的依赖服务状态收进一个工作台中</title><link>https://blog.seeridia.top/blog/statushub</link><guid isPermaLink="true">https://blog.seeridia.top/blog/statushub</guid><description>从产品动机、Miora 视觉设计到 CodeBuddy 研发，记录 StatusHub 如何成为一个能运行的服务状态工作台。</description><pubDate>Mon, 14 Sep 2026 15:30:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;犀牛鸟 实战任务 第二任务&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;犀牛鸟的第二个任务：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;用 Agent 落地你的产品。使用 TDesign 组件完成一个功能完整的小应用，并使用任务一的色阶生成能力提供主题色切换；使用 Miora 产出 UI 稿与视觉资产，再使用 CodeBuddy 将它开发成真正能够运行、访问和演示的产品。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;上一篇文章里，我从一个主色出发，做出了 &lt;a href=&quot;https://blog.seeridia.top/blog/okramp/&quot;&gt;OKRamp&lt;/a&gt;；这一次，我想知道它离开色板工作台以后，能不能真的进入一个产品里。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;其实这边另外可以说的是，在上一个任务的 &lt;a href=&quot;https://blog.seeridia.top/blog/okramp/&quot;&gt;OKRamp&lt;/a&gt; 中，其实其 playground 站点本身也是使用 TDesign 组件开发的，符合 TDesign 设计规范的应用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;我选择的是——多服务融合的状态监控和通知系统&lt;/p&gt;
&lt;p&gt;项目的在线体验与源码都在这里：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://statushub.seeridia.top/&quot;&gt;https://statushub.seeridia.top&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;由于为了接入国外服务的状态页，该系统部署在国外服务器上，国内访问可能速度较慢或者同时需要特殊的网络环境。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/Seeridia/StatusHub&quot;&gt;https://github.com/Seeridia/StatusHub&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;背景&lt;/h2&gt;
&lt;p&gt;现代产品很少只依赖自己的服务器。云平台、代码托管、支付、邮件、AI API 与 CDN，任何一环出问题，最后都可能变成用户眼中的“你的产品坏了”。&lt;/p&gt;
&lt;p&gt;这些服务通常都有自己的状态页。问题是，它们散落在不同网站上，也使用不同的结构。真的出现异常时，我需要先猜是哪一家，再逐个打开页面，判断事故是否与自己使用的服务有关，最后把消息转给团队。&lt;/p&gt;
&lt;p&gt;如果依赖只有两三家，这还算不上问题；当依赖逐渐变多，它就会变成一种很机械的值班工作。&lt;/p&gt;
&lt;p&gt;所以我最初想做的并不是另一个状态页，而是一个&lt;strong&gt;状态页的工作台&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把团队依赖的外部服务放在同一处。&lt;/li&gt;
&lt;li&gt;把不同来源的事故整理成一致的状态与时间线。&lt;/li&gt;
&lt;li&gt;只有变化符合规则时，才把消息送到需要知道的人那里。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;向你介绍 StatusHub&lt;/h2&gt;
&lt;p&gt;StatusHub 是一个可以自托管的服务状态监控与通知平台。用户输入公开状态页地址，系统识别页面使用的状态引擎，持续采集事故与组件状态，再按照团队配置的规则发送通知。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/overview.CEMsaJng_19pA4R.webp&quot; alt=&quot;StatusHub 工作台总览&quot;&gt;&lt;/p&gt;
&lt;p&gt;产品首页优先回答三个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;哪些外部依赖正在出问题？&lt;/li&gt;
&lt;li&gt;这件事是否可能影响我的团队？&lt;/li&gt;
&lt;li&gt;对应的通知有没有成功送达？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;围绕这三个问题，工作台形成了总览、服务、事件、通知规则、通知渠道、投递记录与设置七个主要页面。&lt;/p&gt;
&lt;h3&gt;快速添加&lt;/h3&gt;
&lt;p&gt;我不希望用户在添加服务之前先理解“Statuspage 适配器”或“incident.io Widget API”是什么。&lt;/p&gt;
&lt;p&gt;添加数据源时，用户先在 TDesign &lt;code&gt;SelectInput&lt;/code&gt; 中输入服务名称。选择已经收录的服务，例如 OpenAI，页面会自动补全它的官方状态页地址；目录之外的服务仍然可以输入名称并手动填写 URL。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/add-source.C7gmtRXs_Z143LxD.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;提交之前，后端会先探测这个地址，返回识别到的适配器与资源能力。&lt;/p&gt;
&lt;p&gt;目前 StatusHub 支持 Atlassian Statuspage、incident.io、Instatus、Better Stack、Status.io、Cachet、Gatus、cState、受控 HTML recipe 与 AWS public Health 等来源。这里的重点不是为每一家厂商写一份爬虫，而是识别它们背后共用的状态页引擎。&lt;/p&gt;
&lt;h3&gt;统一事故模型&lt;/h3&gt;
&lt;p&gt;不同状态页会使用不同字段描述同一件事。StatusHub 在采集后把它们归一为统一的服务、事故、影响等级、阶段与更新时间，再保留上游原文组成时间线。&lt;/p&gt;
&lt;p&gt;一条变化进入系统以后，会依次经过这些环节：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  A[公开状态页] --&gt; B[探测与适配器]
  B --&gt; C[统一事故模型]
  C --&gt; D[PostgreSQL 与 Outbox]
  D --&gt; E[NATS JetStream]
  E --&gt; F[通知规则匹配]
  F --&gt; G[Slack / 飞书 / Webhook / ...]
  G --&gt; H[投递记录与重试]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条链路让产品完成了一个真正的闭环：&lt;strong&gt;接入来源 → 发现变化 → 判断是否相关 → 发出通知 → 确认投递结果。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;设计 · Miora&lt;/h2&gt;
&lt;p&gt;状态监控产品天然会出现大量表格、状态标签和数字。如果视觉稿只追求一张漂亮的大屏，真正放入长标题、失败原因和配置表单以后，很容易失去秩序。&lt;/p&gt;
&lt;p&gt;所以在 Miora 阶段，我先确定的不是某一张页面，而是一组可以贯穿产品的视觉原则：清晰、克制、适合长时间阅读；状态依靠颜色、图标和文字共同表达；品牌色负责操作与识别，不与红、橙、绿这些业务状态色争夺含义。&lt;/p&gt;
&lt;p&gt;同时，我也希望能够快速使用 TDesign 组件完成页面布局，符合 TDesign 的设计规范和交互模式。这样在进入 CodeBuddy 阶段时，Agent 可以直接把视觉稿映射为组件，而不是需要重新设计。&lt;/p&gt;
&lt;p&gt;Miora 产出的视觉内容最终落成三类资产：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;工作台融合稿&lt;/strong&gt;：左侧导航、顶部工具栏与内容工作区组成基础框架，总览用指标卡、服务列表和最近事件建立信息层级。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;登录页视觉&lt;/strong&gt;：中央保留产品说明与登录入口，两侧使用立体装饰构成背景，品牌墙展示产品能够连接的服务生态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;品牌资产&lt;/strong&gt;：StatusHub 的脉冲折线 Logo 与薄荷绿色彩方向。折线同时像监控波形与状态变化，&lt;code&gt;Hub&lt;/code&gt; 则表达把分散来源汇到一起。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如，我在 Miora 中设计了他的 LOGO：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/logo.C2N8RL_-_tcP69.webp&quot; alt=&quot;StatusHub Logo&quot;&gt;&lt;/p&gt;
&lt;p&gt;比如在登录页中，我让 Miora 模仿 TDesign Starter 的登录页背景，也同样生成了一个立体风格的背景作为登录页。同时 Miora 能很方便的通过标记等等方式去提示 Agent 在对应地方进行修改，大大提高了沟通效率&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/loginbcc.WQSYExHi_Z2fX0SY.webp&quot; alt=&quot;StatusHub 登录页视觉稿&quot;&gt;&lt;/p&gt;
&lt;p&gt;以及，直接在 Miora 中生成了 StatusHub 的 UI 设计稿：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/overviewbcc.CV5r7sCh_Z24taqs.webp&quot; alt=&quot;StatusHub 工作台视觉稿&quot;&gt;&lt;/p&gt;
&lt;p&gt;这份设计稿在下面的 CodeBuddy 阶段被 Agent 直接映射为 TDesign 组件，保留了布局、信息层级、状态标签和交互模式，为后续的开发节省了大量时间。&lt;/p&gt;
&lt;h2&gt;研发 · CodeBuddy&lt;/h2&gt;
&lt;p&gt;我没有把需求一次性写成“帮我做一个监控后台”。这样的提示或许能很快得到一张可以点击的页面，却很难得到一个能够解释数据、可靠发送通知并持续运行的产品。&lt;/p&gt;
&lt;p&gt;在 CodeBuddy 中，这个项目经历了三个很清楚的阶段：&lt;strong&gt;先调研，再 Plan，最后开发&lt;/strong&gt;。它们不是三个互不相关的对话，而是一条逐渐收窄问题的路径。调研决定应该解决什么，Plan 决定先验证哪一部分，开发则不断用真实结果修正规划。&lt;/p&gt;
&lt;h3&gt;01 · 调研：先寻找问题的结构&lt;/h3&gt;
&lt;p&gt;最开始，我只知道自己想把 GitHub、Cloudflare、OpenAI、Anthropic 与 AWS 的状态放在一起。CodeBuddy 没有立刻创建项目，而是先调查了两个方向：有没有相近的开源方案，以及这些厂商究竟怎样提供状态数据。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/CodeBuddy_%E8%B0%83%E7%A0%94.CGYq3dOm_2sUAPQ.webp&quot; alt=&quot;CodeBuddy 调研同类方案、厂商状态页与数据格式&quot;&gt;&lt;/p&gt;
&lt;p&gt;调研得到的第一个关键发现是：GitHub、Cloudflare、OpenAI 与 Anthropic 虽然属于不同公司，却都使用 Atlassian Statuspage。它们的 &lt;code&gt;summary.json&lt;/code&gt;、组件和事故接口具有相似结构。与其为四家厂商分别写四份代码，不如先做一个通用的 Statuspage Provider。&lt;/p&gt;
&lt;p&gt;AWS 则是一个很有价值的反例。它使用自己的公开数据格式，甚至还需要处理 UTF-16 JSON，不能硬塞进同一个解析器。于是系统从一开始就采用“通用 Provider + 特殊来源独立适配”的结构。&lt;/p&gt;
&lt;p&gt;CodeBuddy 还对照了 &lt;code&gt;statusphere&lt;/code&gt; 与 &lt;code&gt;ai-status-hub&lt;/code&gt; 等项目。前者证明“采集器、任务调度、API 与通知”可以拆成清楚的职责；后者提醒我必须处理误报：&lt;strong&gt;来源抓取失败只能表示数据未知，不能被解释成厂商故障。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;所以调研并没有直接产出页面，而是先回答了三个会影响整个产品的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;哪些厂商可以共享一套采集逻辑？&lt;/li&gt;
&lt;li&gt;哪些来源必须保留自己的适配器？&lt;/li&gt;
&lt;li&gt;怎样区分厂商异常与 StatusHub 自己没有采集到数据？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一阶段最大的价值，是把“聚合几个网页”的想法变成了一个可以扩展的数据模型。&lt;/p&gt;
&lt;h3&gt;02 · Plan：把结论变成可以验收的路径&lt;/h3&gt;
&lt;p&gt;调研完成以后，CodeBuddy 没有把所有功能塞进一次开发，而是先生成一份完整的 &lt;code&gt;PLAN.md&lt;/code&gt;。里面记录架构选择、风险、数据模型、里程碑与每一阶段的验收条件，再交给我审阅。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/CodeBuddy_plan.BCSDTO3p_19MBed.webp&quot; alt=&quot;CodeBuddy 将调研结论整理为 PLAN.md&quot;&gt;&lt;/p&gt;
&lt;p&gt;初始 Plan 把工作拆成 M0 到 M5：先搭建骨架，再接通四家共用 Statuspage 的厂商；随后完成变化检测和通知；再补 AWS Provider；最后才进入订阅、界面与加固。这个顺序有一个很明确的目的：&lt;strong&gt;先证明最短的数据链路可以跑通，再增加来源、界面和工程复杂度。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Plan 也提前写出了成本最大的分叉。通用 Provider 能覆盖四个首批来源，AWS 必须单独实现；通知库可以先帮助验证链路，但敏感 Token 不能进入配置仓库；事故只有在状态发生变化时才触发，并且需要去重和静默窗口。&lt;/p&gt;
&lt;p&gt;不过，Plan 并不是最终架构的承诺。截图记录的是项目早期版本，当时为了快速验证，方案还是 Go 单体、SQLite 与第三方通知库。随着产品从原型走向可部署版本，SQLite 被 PostgreSQL 取代，进程内链路演进为 Outbox 与 NATS JetStream，通知也变成了可以查看重试与投递结果的独立管线。&lt;/p&gt;
&lt;p&gt;我更愿意把 Plan 理解为一张路线图：它让 Agent 和我知道下一步要证明什么，也允许已经被事实推翻的选择被替换。&lt;/p&gt;
&lt;h3&gt;03 · 开发：让每个文件都对应一个可验证结果&lt;/h3&gt;
&lt;p&gt;进入开发阶段以后，CodeBuddy 按照 Plan 逐项推进。截图里可以看到它先重整运行时状态，再创建持久化层、探测器、Provider 注册表与 Statuspage 实现。每次生成或修改文件后，它会继续处理类型错误、缺失方法与重复逻辑，而不是等所有代码写完后才尝试运行。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/CodeBuddy_%E5%BC%80%E5%8F%91.r4FSrQ8O_1mdPSC.webp&quot; alt=&quot;CodeBuddy 按计划创建存储、探测和 Provider 代码&quot;&gt;&lt;/p&gt;
&lt;p&gt;CodeBuddy 在这里承担了调研、文件修改、迁移、测试和浏览器回归，但它并没有替我做完产品判断。例如“首次采集不发送历史事故”“通知规则应该选择服务而不是填写 UUID”“服务状态与采集新鲜度必须分开”，都需要我在看到中间结果以后继续修正规则。&lt;/p&gt;
&lt;p&gt;开发中也出现过很典型的偏差：页面一度为了贴近视觉稿覆盖了过多 TDesign 样式，组件原本的交互层级与信息密度反而被削弱。之后我让 CodeBuddy 重新以 TDesign Token 和 Starter 规范为边界，保留 Miora 稿中的布局与信息关系，收回多余的局部样式。&lt;/p&gt;
&lt;p&gt;这三个阶段真正形成的是一个反馈环：调研减少错误假设，Plan 控制一次开发的范围，开发结果再返回来修正 Plan。Agent 带来的速度，最终需要靠这条反馈环变成可以交付的质量。&lt;/p&gt;
&lt;h2&gt;Design Token · OKRamp 色阶生成&lt;/h2&gt;
&lt;p&gt;这次作业要求复用任务一的色阶能力。我不想在 CSS 里把 Logo 的绿色复制几十次，也不想只覆盖 &lt;code&gt;--td-brand-color&lt;/code&gt;，让 Hover、禁用态、链接、边框与深色模式仍然留在原来的蓝色体系里。&lt;/p&gt;
&lt;p&gt;StatusHub 直接安装了 &lt;code&gt;@okramp/core&lt;/code&gt; 与 &lt;code&gt;@okramp/tdesign&lt;/code&gt;。构建前，脚本以 Logo 的薄荷绿 &lt;code&gt;#20C897&lt;/code&gt; 为输入，同时生成品牌色阶和带有相同冷暖倾向的中性色阶：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/statushub&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;生成结果继续映射为 TDesign 的语义 Token：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;浅色：brand-7 → 默认操作色，brand-8 → Hover，brand-9 → Active
深色：brand-5 → 默认操作色，brand-4 → Hover，brand-6 → Active
背景、容器、文字与边框 → 由 14 级关联中性色映射
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;浅色主题需要较深的绿色承载白色按钮文字；深色主题则选择更亮的阶位，保证它在深背景上仍然清楚。&lt;code&gt;contrastPolicy: &quot;adjust&quot;&lt;/code&gt; 会在色阶中寻找满足目标对比度、又尽量接近原角色的颜色，生成阶段若仍有检查项不通过便直接失败。&lt;/p&gt;
&lt;p&gt;最终，Button、Link、Input、Select、Table、Tag、Drawer 与 Dialog 都从同一套 &lt;code&gt;--td-*&lt;/code&gt; 变量读取颜色。主题切换只改变 &lt;code&gt;theme-mode&lt;/code&gt;，不需要在运行时重新计算整张色板。圆角也集中映射到 TDesign Token，普通控件从 8px 开始，容器逐级增加，圆形和胶囊形组件仍保留原有形状。&lt;/p&gt;
&lt;h2&gt;交付与验证&lt;/h2&gt;
&lt;p&gt;StatusHub 当前已经提供可访问的 HTTPS 演示地址和完整源码仓库。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://statushub.seeridia.top/&quot;&gt;https://statushub.seeridia.top&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;由于为了接入国外服务的状态页，该系统部署在国外服务器上，国内访问可能速度较慢或者同时需要特殊的网络环境。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/Seeridia/StatusHub&quot;&gt;https://github.com/Seeridia/StatusHub&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;结束语&lt;/h2&gt;
&lt;p&gt;做完 OKRamp 时，我得到的是一个能够解释颜色如何生成的工具；把它放进 StatusHub 以后，我才真正看到一套色阶如何影响按钮、背景、文字、边框、Hover 与深色模式。&lt;/p&gt;
&lt;p&gt;而 StatusHub 本身也经历了类似的过程。它最初只是“把几家状态页放在一起”的想法，经过产品边界、视觉系统、组件语义、数据一致性和通知可靠性的反复补全，才成为一个能够接入、判断、通知与追踪的小应用。&lt;/p&gt;
&lt;p&gt;Agent 让从想法到运行产品的距离明显缩短了，Miora、CodeBuddy 让这个过程更可控、更可验证。它们的组合，让我在短时间内完成了一个从设计到研发、从视觉到代码、从单一网页到完整产品的闭环。&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.D9l1NJB4.webp"/><category>design</category><category>agent</category><category>tdesign</category><category>react</category><category>go</category></item><item><title>重新构想一条色阶</title><link>https://blog.seeridia.top/blog/okramp</link><guid isPermaLink="true">https://blog.seeridia.top/blog/okramp</guid><description>从一个主色出发，聊聊如何用 OKLCH 生成自然的色阶。</description><pubDate>Mon, 14 Sep 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;起源于犀牛鸟的任务：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;TDesign 各技术栈站点底部都有主题生成器挂件。输入一个主色后，它会扩展出一整套连续色阶，承载从最浅背景、Hover 高亮到最深文字强调等不同场景。&lt;/p&gt;
&lt;p&gt;请使用 TypeScript 提供一个函数：输入任意主色，自动“变出”一套 10 级、由浅到深、过渡自然的完整主色阶，并提供 HTML 或任意形式的 UI 界面供体验。如果你能同时考虑深色模式下的色阶效果，以及生成与主题色相关联的中性色阶，将是更好的实现。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;其实我在此之前就对颜色相关的一些工作比较感兴趣，所以也想更深入地去了解，想了解这些颜色为什么这样排列，极端输入下应该如何取舍，以及一条色阶怎样继续长成主题。&lt;/p&gt;
&lt;p&gt;项目的在线体验和文档都在这里：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://okramp.seeridia.top/&quot;&gt;https://okramp.seeridia.top&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/seeridia/okramp&quot;&gt;https://github.com/seeridia/okramp&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;背景&lt;/h2&gt;
&lt;p&gt;最初看到这个需求时，我也把它理解成一个最普通的插值问题：给定主色，再找一个浅色和一个深色，在它们之间取十个点。&lt;/p&gt;
&lt;p&gt;实现并不困难。真正困难的是，当这些颜色摆在一起时，它们是否连续、美观。&lt;/p&gt;
&lt;p&gt;哪该如何插值呢&lt;/p&gt;
&lt;p&gt;为了把差异直接摆出来，我用同一个主色 &lt;code&gt;#0052D9&lt;/code&gt; 做了一组最简单的实验：前七阶从白色插值到主色，后三阶再从主色插值到黑色。除了颜色空间不同，其余条件完全相同。&lt;/p&gt;
&lt;h3&gt;sRGB 与 HSL&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;就拿我们更常见的 sRGB 来插值，直接让红、绿、蓝三个通道线性变化。色值很好计算，但通道走过相同距离，不代表人眼感受到的明度也走过相同距离。所以你也能明显能感受到他们的亮度并不均匀。&lt;/p&gt;
&lt;p&gt;下面这个图就比较直观了&lt;/p&gt;
&lt;p&gt;这里其实有两层的不对等。&lt;/p&gt;
&lt;p&gt;第一层发生在屏幕上。sRGB 保存的通道值经过一条近似 Gamma 的传递函数编码，它不是灯珠发光强度的直接刻度。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;这两组颜色是两种不同的中点定义。&lt;code&gt;#808080&lt;/code&gt; 的三个通道都位于 &lt;code&gt;00&lt;/code&gt; 和 &lt;code&gt;FF&lt;/code&gt; 的数值中点；但将 sRGB 编码还原成线性光后，它的相对亮度只有约 21.6%。真正具有 50% 线性光亮度的灰色接近 &lt;code&gt;#BCBCBC&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这说明 &lt;strong&gt;sRGB 数值中点不等于视觉中点&lt;/strong&gt;。但 50% 的视觉中点也不等于 50% 的感知明度：眼睛对亮度的响应同样是非线性的，因此 &lt;code&gt;#BCBCBC&lt;/code&gt; 看起来会明显偏亮，&lt;code&gt;#808080&lt;/code&gt; 反而更接近通常所说的视觉中灰。第一层不对等发生在 sRGB 编码与屏幕实际发光强度之间，至于人最终觉得它有多亮，还需要继续考虑视觉系统。&lt;/p&gt;
&lt;p&gt;第二层发生在眼睛与大脑里。人眼没有三种分别读取屏幕 R、G、B 数字的传感器。视网膜中 S、M、L 三类视锥细胞各自覆盖一段相互重叠的光谱；单个视锥只能报告自己吸收了多少光子，颜色需要由三类视锥的相对响应推断。&lt;/p&gt;
&lt;p&gt;因此，sRGB 中每个通道等量前进，只保证设备编码坐标里的步长相等。转换到人眼使用的明暗与拮抗信号后，每一步的距离会被不同程度地拉伸或压缩。&lt;/p&gt;
&lt;p&gt;不过，sRGB 编码本身&lt;strong&gt;部分利用了视觉的非线性&lt;/strong&gt;，因此它比直接保存线性光更适合有限位深的图像编码；但它仍然不是三维的感知均匀颜色空间。W3C 对它的描述很直接：sRGB 既不是线性光空间，也不是感知均匀空间。若目标是物理正确地混合光，应在线性 sRGB 中插值；若目标是让颜色沿途更接近感知等距，则更适合使用 &lt;a href=&quot;https://www.w3.org/TR/css-color-4/#interpolation-space&quot;&gt;Oklab 或 OKLCH&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;这边就稍微简单讲讲，HSL 把色相、饱和度和亮度分开了，控制起来比 RGB 直观。简单来说，它把颜色参数变得更容易理解（而不是对着 RGB 三个值去调整），却没有让颜色之间的距离更符合人眼感受。&lt;/p&gt;
&lt;p&gt;甚至本质上来说，HSL 只是把 sRGB 立方体重新表示成圆柱坐标，本质没有区别。它没有根据视觉实验重新校准空间，因此在圆柱中相同长度的路径，不代表相同的感知色差。&lt;/p&gt;
&lt;h3&gt;CIELAB 插值&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;到 CIELAB，目标才从“方便描述颜色”变成了“让数值距离近似人眼感受到的色差”。它用 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;msup&gt;&lt;mi&gt;L&lt;/mi&gt;&lt;mo&gt;∗&lt;/mo&gt;&lt;/msup&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;L^*&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 描述感知明度，用 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;msup&gt;&lt;mi&gt;a&lt;/mi&gt;&lt;mo&gt;∗&lt;/mo&gt;&lt;/msup&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;a^*&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 表示红—绿方向，用 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;msup&gt;&lt;mi&gt;b&lt;/mi&gt;&lt;mo&gt;∗&lt;/mo&gt;&lt;/msup&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;b^*&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 表示黄—蓝方向。在这个空间中走过相同距离，通常比 RGB 和 HSL 更接近相同的视觉变化。&lt;/p&gt;
&lt;p&gt;上面的过渡因此平稳了不少，尤其是浅色到主色这一段。不过，CIELAB 的感知均匀性只是一种近似，因为它假设整套颜色空间可以共用一把固定的欧氏距离：&lt;/p&gt;
&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot; display=&quot;block&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi mathvariant=&quot;normal&quot;&gt;Δ&lt;/mi&gt;&lt;msubsup&gt;&lt;mi&gt;E&lt;/mi&gt;&lt;mn&gt;76&lt;/mn&gt;&lt;mo lspace=&quot;0em&quot; rspace=&quot;0em&quot;&gt;∗&lt;/mo&gt;&lt;/msubsup&gt;&lt;mo&gt;=&lt;/mo&gt;&lt;msqrt&gt;&lt;mrow&gt;&lt;mo stretchy=&quot;false&quot;&gt;(&lt;/mo&gt;&lt;mi mathvariant=&quot;normal&quot;&gt;Δ&lt;/mi&gt;&lt;msup&gt;&lt;mi&gt;L&lt;/mi&gt;&lt;mo lspace=&quot;0em&quot; rspace=&quot;0em&quot;&gt;∗&lt;/mo&gt;&lt;/msup&gt;&lt;msup&gt;&lt;mo stretchy=&quot;false&quot;&gt;)&lt;/mo&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msup&gt;&lt;mo&gt;+&lt;/mo&gt;&lt;mo stretchy=&quot;false&quot;&gt;(&lt;/mo&gt;&lt;mi mathvariant=&quot;normal&quot;&gt;Δ&lt;/mi&gt;&lt;msup&gt;&lt;mi&gt;a&lt;/mi&gt;&lt;mo lspace=&quot;0em&quot; rspace=&quot;0em&quot;&gt;∗&lt;/mo&gt;&lt;/msup&gt;&lt;msup&gt;&lt;mo stretchy=&quot;false&quot;&gt;)&lt;/mo&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msup&gt;&lt;mo&gt;+&lt;/mo&gt;&lt;mo stretchy=&quot;false&quot;&gt;(&lt;/mo&gt;&lt;mi mathvariant=&quot;normal&quot;&gt;Δ&lt;/mi&gt;&lt;msup&gt;&lt;mi&gt;b&lt;/mi&gt;&lt;mo lspace=&quot;0em&quot; rspace=&quot;0em&quot;&gt;∗&lt;/mo&gt;&lt;/msup&gt;&lt;msup&gt;&lt;mo stretchy=&quot;false&quot;&gt;)&lt;/mo&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msup&gt;&lt;/mrow&gt;&lt;/msqrt&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;\Delta E_{76}^{*}
=
\sqrt{(\Delta L^{*})^{2}+(\Delta a^{*})^{2}+(\Delta b^{*})^{2}}&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;
&lt;p&gt;但视觉实验发现，人眼的“最小可觉差”会随颜色位置变化。它在色彩空间中表现为大小、方向不同的椭圆，而不是处处相同的圆：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;某些区域很小的数值变化就能被察觉。&lt;/li&gt;
&lt;li&gt;某些区域需要更大的变化才能被察觉。&lt;/li&gt;
&lt;li&gt;蓝色区域还存在明显的色相与彩度耦合。&lt;/li&gt;
&lt;li&gt;高彩度颜色距离中性轴更远，更容易暴露模型偏差。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，人眼在有些区域更敏感，在另一些区域更迟钝；一个固定的欧氏距离无法同时贴合所有区域。这与视锥细胞和后续拮抗通道的非线性响应有关，亮度、彩度和周围环境还会继续影响最终感受到的色差。&lt;/p&gt;
&lt;p&gt;当然，后来的 CIEDE2000 色差公式因此加入了随明度、彩度和色相变化的权重，并专门增加蓝色区域的旋转修正项。但这些本质上都是拟补的一些手动，换句话说，如果 CIELAB 本身处处均匀，这些修正就没有存在的必要。&lt;a href=&quot;https://hajim.rochester.edu/ece/sites/gsharma/papers/cic04ciede2000.pdf&quot;&gt;原始论文&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;下面举一个比较明显的例子：在 CIELCH 中保持数值色相不变，只增加彩度&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/cielch-blue-slice.CJm8aMLT_Z1cEKfF.webp&quot; alt=&quot;CIELCH 固定蓝色色相的 sRGB 色域切片&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;CIELCH：固定数值色相为 301.37°。图片来源：&lt;a href=&quot;https://www.w3.org/TR/css-color-4/#hue-curvature&quot;&gt;W3C CSS Color 4&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/oklch-blue-slice.dC_8CbCe_Z2g66a1.webp&quot; alt=&quot;OKLCH 固定蓝色色相的 sRGB 色域切片&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;OKLCH：固定数值色相为 264.0585°。图片来源：&lt;a href=&quot;https://www.w3.org/TR/css-color-4/#hue-curvature&quot;&gt;W3C CSS Color 4&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;这两张图可以这样看：纵轴是明度，越向上越亮；横轴是彩度，越向右越鲜艳。灰色区域超出了 sRGB 能显示的范围，白色轮廓内则是屏幕能够呈现的颜色。&lt;/p&gt;
&lt;p&gt;两张图分别固定了 CIELCH 与 OKLCH 中对应 sRGB 蓝色的色相，所以观察时&lt;strong&gt;比较彩色区域从左向右是否始终像同一种蓝色&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;先看第一张 CIELCH 图。在相近明度下横向移动，只增加彩度，&lt;strong&gt;左侧和中段明显偏紫，接近最右端时才逐渐变成蓝色&lt;/strong&gt;。计算中的色相角始终是 301.37°，人眼看到的色相却发生了变化，这正是 hue curvature。&lt;/p&gt;
&lt;p&gt;再看第二张 OKLCH 图。用同样方式从左向右观察，颜色主要是从灰蓝逐渐变成鲜蓝，视觉色相基本保持稳定。&lt;/p&gt;
&lt;p&gt;这个对照说明，CIELAB 的问题是它的“固定数值色相”没有对应到人眼感受到的固定色相。&lt;/p&gt;
&lt;p&gt;所以回过头来看看&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;你能很明显的发现，浅色部分的蓝色偏紫。它们都是在 CIELAB 中保持数值色相不变，但人眼感受到的色相却发生了变化。&lt;/p&gt;
&lt;h2&gt;为什么是 OKLCH（OKLAB）&lt;/h2&gt;
&lt;p&gt;回头看前面的几种方案，它们其实各自解决了一部分问题。&lt;/p&gt;
&lt;p&gt;sRGB 忠实描述设备，却不适合拿通道数值当作视觉距离；HSL 让参数变得容易理解，却没有改变 sRGB 不均匀的底层；CIELAB 开始认真拟合人的感知，但在蓝色、高彩度和色相线性上仍有明显偏差。&lt;/p&gt;
&lt;p&gt;我需要的颜色空间至少要做到三件事：明度变化看起来稳定，改变彩度时尽量不带动色相，计算过程还要足够简单，才能在浏览器里实时生成整套主题。Oklab 正好是围绕这些目标设计的。&lt;/p&gt;
&lt;h3&gt;基于视觉实验数据得到的色彩空间&lt;/h3&gt;
&lt;p&gt;Oklab 沿用了 IPT 颜色空间简洁的计算结构。颜色先从 XYZ 转换成近似三类视锥响应的 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;l&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;l&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;、&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;m&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;m&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;、&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;s&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;s&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;，再经过立方根形式的非线性压缩，最后组合成一条明度轴和两条拮抗色轴：&lt;/p&gt;
&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot; display=&quot;block&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mrow&gt;&lt;mo fence=&quot;true&quot;&gt;(&lt;/mo&gt;&lt;mtable rowspacing=&quot;0.16em&quot; columnalign=&quot;center&quot; columnspacing=&quot;1em&quot;&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;l&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;m&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;s&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;/mtable&gt;&lt;mo fence=&quot;true&quot;&gt;)&lt;/mo&gt;&lt;/mrow&gt;&lt;mo&gt;=&lt;/mo&gt;&lt;msub&gt;&lt;mi mathvariant=&quot;bold&quot;&gt;M&lt;/mi&gt;&lt;mn&gt;1&lt;/mn&gt;&lt;/msub&gt;&lt;mrow&gt;&lt;mo fence=&quot;true&quot;&gt;(&lt;/mo&gt;&lt;mtable rowspacing=&quot;0.16em&quot; columnalign=&quot;center&quot; columnspacing=&quot;1em&quot;&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;X&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;Y&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;Z&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;/mtable&gt;&lt;mo fence=&quot;true&quot;&gt;)&lt;/mo&gt;&lt;/mrow&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;\begin{pmatrix}
l \\
m \\
s
\end{pmatrix}
=
\mathbf{M}_{1}
\begin{pmatrix}
X \\
Y \\
Z
\end{pmatrix}&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;
&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot; display=&quot;block&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mrow&gt;&lt;mo fence=&quot;true&quot;&gt;(&lt;/mo&gt;&lt;mtable rowspacing=&quot;0.16em&quot; columnalign=&quot;center&quot; columnspacing=&quot;1em&quot;&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;L&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;a&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mi&gt;b&lt;/mi&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;/mtable&gt;&lt;mo fence=&quot;true&quot;&gt;)&lt;/mo&gt;&lt;/mrow&gt;&lt;mo&gt;=&lt;/mo&gt;&lt;msub&gt;&lt;mi mathvariant=&quot;bold&quot;&gt;M&lt;/mi&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msub&gt;&lt;mrow&gt;&lt;mo fence=&quot;true&quot;&gt;(&lt;/mo&gt;&lt;mtable rowspacing=&quot;0.16em&quot; columnalign=&quot;center&quot; columnspacing=&quot;1em&quot;&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mroot&gt;&lt;mi&gt;l&lt;/mi&gt;&lt;mn&gt;3&lt;/mn&gt;&lt;/mroot&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mroot&gt;&lt;mi&gt;m&lt;/mi&gt;&lt;mn&gt;3&lt;/mn&gt;&lt;/mroot&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;mtr&gt;&lt;mtd&gt;&lt;mstyle scriptlevel=&quot;0&quot; displaystyle=&quot;false&quot;&gt;&lt;mroot&gt;&lt;mi&gt;s&lt;/mi&gt;&lt;mn&gt;3&lt;/mn&gt;&lt;/mroot&gt;&lt;/mstyle&gt;&lt;/mtd&gt;&lt;/mtr&gt;&lt;/mtable&gt;&lt;mo fence=&quot;true&quot;&gt;)&lt;/mo&gt;&lt;/mrow&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;\begin{pmatrix}
L \\
a \\
b
\end{pmatrix}
=
\mathbf{M}_{2}
\begin{pmatrix}
\sqrt[3]{l} \\
\sqrt[3]{m} \\
\sqrt[3]{s}
\end{pmatrix}&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;
&lt;p&gt;真正重要的是矩阵 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;msub&gt;&lt;mi mathvariant=&quot;bold&quot;&gt;M&lt;/mi&gt;&lt;mn&gt;1&lt;/mn&gt;&lt;/msub&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;\mathbf{M}_{1}&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 与 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;msub&gt;&lt;mi mathvariant=&quot;bold&quot;&gt;M&lt;/mi&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msub&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;\mathbf{M}_{2}&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 如何确定。Oklab 的作者 Björn Ottosson 准备了三组用于检验明度、彩度和色相的数据：明度与彩度数据由 CAM16 在正常观看条件下生成，等色相数据则来自用于建立 IPT 的视觉实验。随后调整矩阵参数，使模型尽可能把“同样亮”“同样鲜艳”和“同一色相”的颜色分别放在对应的等值线上。&lt;a href=&quot;https://bottosson.github.io/posts/oklab/#how-oklab-was-derived&quot;&gt;Oklab 的推导过程&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;可以把 &lt;strong&gt;IPT&lt;/strong&gt; 理解成一套轻量的颜色坐标。它把颜色拆成“有多亮”，以及“偏红还是偏绿”“偏蓝还是偏黄”。它计算简单，而且改变颜色深浅时不容易突然跑色；不足之处是，它判断一个颜色有多亮、多鲜艳时还不够准确。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CAM16&lt;/strong&gt; 更像一台复杂的视觉模拟器。同一个色值放在明亮房间、昏暗房间或不同背景上，看起来可能并不一样；CAM16 会把这些观看条件也考虑进去，再估计人眼觉得它有多亮、多鲜艳。它更全面，但计算也复杂得多。&lt;/p&gt;
&lt;p&gt;Oklab 的做法可以概括为：&lt;strong&gt;使用 IPT 简单好算的结构，学习 CAM16 对明度和彩度的判断，再用视觉实验校准色相。&lt;/strong&gt; 最终得到一套接近复杂视觉模型，同时能在浏览器里快速计算的颜色坐标。&lt;/p&gt;
&lt;p&gt;因此 Oklab 针对图像处理与颜色混合的需求，在准确度、稳定性和计算复杂度之间取得了更合适的平衡。&lt;/p&gt;
&lt;h3&gt;OKLAB 到 OKLCH&lt;/h3&gt;
&lt;p&gt;Oklab 使用 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;L&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;L&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;、&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;a&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;a&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;、&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;b&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;b&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 直角坐标，适合计算颜色距离与进行一般混合。OKLCH 只是同一个空间的圆柱表示：&lt;/p&gt;
&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot; display=&quot;block&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;C&lt;/mi&gt;&lt;mo&gt;=&lt;/mo&gt;&lt;msqrt&gt;&lt;mrow&gt;&lt;msup&gt;&lt;mi&gt;a&lt;/mi&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msup&gt;&lt;mo&gt;+&lt;/mo&gt;&lt;msup&gt;&lt;mi&gt;b&lt;/mi&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msup&gt;&lt;/mrow&gt;&lt;/msqrt&gt;&lt;mo separator=&quot;true&quot;&gt;,&lt;/mo&gt;&lt;mspace width=&quot;2em&quot;&gt;&lt;/mspace&gt;&lt;mi&gt;h&lt;/mi&gt;&lt;mo&gt;=&lt;/mo&gt;&lt;mi mathvariant=&quot;normal&quot;&gt;atan2&lt;/mi&gt;&lt;mo&gt;⁡&lt;/mo&gt;&lt;mo stretchy=&quot;false&quot;&gt;(&lt;/mo&gt;&lt;mi&gt;b&lt;/mi&gt;&lt;mo separator=&quot;true&quot;&gt;,&lt;/mo&gt;&lt;mi&gt;a&lt;/mi&gt;&lt;mo stretchy=&quot;false&quot;&gt;)&lt;/mo&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;C=\sqrt{a^{2}+b^{2}},\qquad h=\operatorname{atan2}(b,a)&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;
&lt;p&gt;Oklab 提供更接近感知均匀的底层坐标，OKLCH 则把同一坐标改写成适合设计色阶的控制方式，只是通过圆柱坐标系把色阶真正关心的三个维度直接暴露出来：&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;L&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;L&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 控制由浅到深，&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;C&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;C&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 控制每一级保留多少色感，&lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;h&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;h&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 控制颜色属于哪个色相。&lt;/p&gt;
&lt;p&gt;当然，OKLCH 也不会自动生成一条好色阶。它仍可能产生超出 sRGB 的颜色，也无法替我决定浅色背景、中间主色和深色文字分别应该有多亮、多鲜艳。它解决的是“怎样独立、稳定地控制颜色属性”，后面文章会讲这件事。&lt;/p&gt;
&lt;h3&gt;其他的一些痛痒&lt;/h3&gt;
&lt;p&gt;其中看到题目就有的一个矛盾是：保留输入原色听起来理所当然，但它有时会破坏完整色阶。&lt;/p&gt;
&lt;p&gt;例如，将 &lt;code&gt;#EAF6FF&lt;/code&gt; 固定在第六阶。它已经接近最浅背景色，算法却还需要在它前面安排五个更浅的颜色。无论怎样插值，这五阶都很容易挤在一起。&lt;/p&gt;
&lt;p&gt;反过来，如果为了让十阶充分展开而重新建立完整明度曲线，输入色就不一定能原样出现在结果里。对于已有品牌规范的项目，这同样可能无法接受。&lt;/p&gt;
&lt;p&gt;完整的明度跨度与固定的输入色位置，在极端输入下可能互相冲突。与其让算法替用户作决定，我更希望把它设计成 API 中明确可选的取舍。&lt;/p&gt;
&lt;h2&gt;向你介绍 OKRamp&lt;/h2&gt;
&lt;p&gt;OKRamp 是一个基于 OKLCH 的 TypeScript 色彩引擎。&lt;/p&gt;
&lt;p&gt;默认情况下，它从输入色提取色相和彩度特征，再重建完整的十阶明度和彩度曲线。除此之外，它也提供两种保留原色的锚点策略。&lt;/p&gt;
&lt;p&gt;整个生成过程分成下面几步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;解析输入&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;接受可解析的 CSS 颜色字符串，将它转换为 OKLCH，并规范化为不透明的 sRGB 种子颜色。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;建立曲线&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;根据策略计算每一阶的目标明度、彩度和色相。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;映射色域&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对超出 sRGB 的候选色固定明度与色相，只降低彩度。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;检查结果&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;输出 HEX、RGB 或 OKLCH，同时检查相邻重复、低感知差异和主题对比度。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;建立主题&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在基础品牌色阶与中性色阶之上，分别映射浅色和深色语义角色。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;核心包不依赖 React 以及任何组件库。体验站、组件库适配器和主题导出都建立在这样的公开 API 之上&lt;/p&gt;
&lt;h3&gt;最小用法&lt;/h3&gt;
&lt;p&gt;对调用者来说，最小用法只有一行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { generateColorScale } from &apos;@okramp/core&apos;

const scale = generateColorScale(&apos;#0052D9&apos;)
console.log(scale.colors)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认会得到：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;[
  &apos;#f0f5ff&apos;,
  &apos;#dce9ff&apos;,
  &apos;#bdd5ff&apos;,
  &apos;#94bbff&apos;,
  &apos;#659cff&apos;,
  &apos;#327aff&apos;,
  &apos;#155dde&apos;,
  &apos;#0244b4&apos;,
  &apos;#002e84&apos;,
  &apos;#001b54&apos;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;输入和输出足够简单，但引擎不会只返回十个字符串。每一阶还包含 OKLCH 数值、来源和色域映射状态；整个结果也能附带诊断信息，让调用者知道哪些颜色经过了调整、哪些相邻阶位可能过于接近。&lt;/p&gt;
&lt;p&gt;例如，截取上面结果的第 6、7 阶和诊断信息，可以看到这样的输出（OKLCH 小数经过取整）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;recommendedIndex&quot;: 6,
  &quot;stops&quot;: [
    {
      &quot;index&quot;: 5,
      &quot;label&quot;: &quot;6&quot;,
      &quot;color&quot;: &quot;#327aff&quot;,
      &quot;oklch&quot;: {
        &quot;l&quot;: 0.61,
        &quot;c&quot;: 0.2112,
        &quot;h&quot;: 261.35
      },
      &quot;inGamut&quot;: true,
      &quot;source&quot;: &quot;gamut-mapped&quot;
    },
    {
      &quot;index&quot;: 6,
      &quot;label&quot;: &quot;7&quot;,
      &quot;color&quot;: &quot;#155dde&quot;,
      &quot;oklch&quot;: {
        &quot;l&quot;: 0.52,
        &quot;c&quot;: 0.2087,
        &quot;h&quot;: 261.35
      },
      &quot;inGamut&quot;: true,
      &quot;source&quot;: &quot;generated&quot;
    }
  ],
  &quot;diagnostics&quot;: {
    &quot;valid&quot;: true,
    &quot;messages&quot;: [
      {
        &quot;code&quot;: &quot;GAMUT_MAPPED&quot;,
        &quot;severity&quot;: &quot;info&quot;,
        &quot;message&quot;: &quot;Some stops required OKLCH chroma reduction to fit sRGB.&quot;,
        &quot;stopIndexes&quot;: [0, 1, 2, 3, 4, 5, 8]
      }
    ],
    &quot;contrastChecks&quot;: []
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;source&lt;/code&gt; 描述这一阶如何得到：&lt;code&gt;generated&lt;/code&gt; 表示目标 OKLCH 本身就在 sRGB 内，&lt;code&gt;gamut-mapped&lt;/code&gt; 表示它原本超出显示范围，引擎保持明度与色相，只降低彩度后得到当前色值。&lt;code&gt;inGamut&lt;/code&gt; 表示最终输出已经可以在 sRGB 中安全显示。&lt;code&gt;recommendedIndex&lt;/code&gt; 使用从零开始的数组索引，因此 &lt;code&gt;6&lt;/code&gt; 指向色阶中的第 7 阶。&lt;/p&gt;
&lt;h3&gt;生成策略&lt;/h3&gt;
&lt;p&gt;前面提到的“原色保留”冲突，在 OKRamp 中被设计成三种明确策略：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;策略&lt;/th&gt;
&lt;th&gt;它优先保证什么&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tonal&lt;/code&gt;，默认&lt;/td&gt;
&lt;td&gt;完整、稳定的明度跨度&lt;/td&gt;
&lt;td&gt;自由输入、极端颜色&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;adaptive-anchor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;保留规范化主色，同时选择更合适的阶位&lt;/td&gt;
&lt;td&gt;品牌色必须保留，但阶位可以变化&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fixed-anchor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;保留规范化主色和指定阶位&lt;/td&gt;
&lt;td&gt;已有明确的设计系统约定&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;adaptive-anchor&lt;/code&gt; 会综合输入明度、目标阶位距离和锚点两侧剩余空间。输入越浅，锚点越靠近浅端；输入越深，锚点越靠近深端。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fixed-anchor&lt;/code&gt; 则尊重调用者的明确要求。即使极端输入导致一侧空间被压缩，引擎也不会悄悄移动它，而是返回低相邻色差或重复阶位诊断。&lt;/p&gt;
&lt;p&gt;这道题强调“输入任意主色”和“完整色阶”。当输入亮度本身不适合位于中间时，重新建立完整曲线通常能得到更可用的结果。代价是输入色不保证原样出现，这一点会在 UI 和 API 文档中明确说明。&lt;/p&gt;
&lt;h3&gt;明度曲线&lt;/h3&gt;
&lt;p&gt;默认十阶的明度预设如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;L = [0.97, 0.93, 0.87, 0.79, 0.70, 0.61, 0.52, 0.43, 0.34, 0.25]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;浅端间隔较小，让大面积背景的变化更克制；从第三阶开始逐渐拉开，到中后段保持相对稳定的差异，为 Hover、默认态、Active 和文字强调留出空间。&lt;/p&gt;
&lt;p&gt;端点默认仍然带有色相，而不是强制变成纯白与纯黑。这能让整条色阶更像同一个颜色家族。如果确实需要黑白端点，也可以显式选择 &lt;code&gt;black-white&lt;/code&gt; 模式。&lt;/p&gt;
&lt;p&gt;这是一条面向界面用途的预设曲线，并不是严格等色差的数学直线。最终是否自然，还要结合彩度变化、色域映射和实际背景一起观察。&lt;/p&gt;
&lt;h3&gt;彩度曲线&lt;/h3&gt;
&lt;p&gt;如果十个颜色保持相同彩度，浅端往往会显得发飘，深端也可能显得生硬。因此，OKRamp 使用一条先升后降的彩度曲线：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;factor = [0.12, 0.30, 0.52, 0.74, 0.92, 1.00, 0.96, 0.86, 0.70, 0.50]

C[i] = min(seed.C, 0.32) × factor[i]
H[i] = seed.H
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;浅端只保留少量彩度，使它更适合作为背景；中段达到峰值，集中表达品牌；深端逐步收敛，但仍然保留色相特征，避免简单地一路灰下去。&lt;/p&gt;
&lt;p&gt;这条共享曲线是一套可解释的基础预设，并不假装对所有色相都已经达到最终最优。黄色、青色和紫色仍会受到各自 sRGB 边界的影响，这也是下一节要处理的问题。&lt;/p&gt;
&lt;h3&gt;色域映射&lt;/h3&gt;
&lt;p&gt;OKLCH 可以描述许多屏幕无法用 sRGB 表示的颜色。高彩度候选色如果直接裁剪 RGB 通道，往往会同时改变它的明度和色相，让局部阶位出现意外跳跃。&lt;/p&gt;
&lt;p&gt;OKRamp 的做法是固定 L 和 H，只降低 C，找到仍处于 sRGB 内的最大彩度&lt;/p&gt;
&lt;p&gt;因此，映射后的颜色尽量保留原定的明暗位置和色相，只牺牲当前设备无法显示的那部分鲜艳程度。发生映射的阶位会被标记为 &lt;code&gt;gamut-mapped&lt;/code&gt;，同时进入诊断结果。&lt;/p&gt;
&lt;p&gt;这里需要区分颜色空间与显示色域。OKLCH 是描述颜色的坐标系统，它能够表示的颜色不受 sRGB 边界限制；sRGB 则是目标设备所能显示的一组有限颜色。当某个 OKLCH 坐标落在 sRGB 之外时，需要映射并不是因为 OKLCH 产生了错误，而是当前显示色域无法呈现那个颜色。&lt;/p&gt;
&lt;p&gt;固定 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;L&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;L&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt; 与 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;H&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;H&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;、只降低 &lt;span class=&quot;katex&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;C&lt;/mi&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;C&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;，利用了 OKLCH 将感知明度、彩度和色相分开表达的特点。它不能保留设备无法显示的鲜艳程度，但能尽量避免映射过程额外带来明度跳跃或色相偏移。&lt;/p&gt;
&lt;h2&gt;主题与生态&lt;/h2&gt;
&lt;p&gt;我不想止步于生成十个色块。界面需要的是语义角色，而不是阶位数字。&lt;/p&gt;
&lt;p&gt;所以，我拓展了 OKRamp 的生态，OKRamp 在基础色阶之上又增加了一层主题模型。并目前提供了下面几个适配器：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@okramp/tdesign&lt;/code&gt;：TDesign&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@okramp/antd&lt;/code&gt;：Ant Design&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@okramp/shadcn&lt;/code&gt;：shadcn/ui&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;中性色&lt;/h3&gt;
&lt;p&gt;如果品牌色是偏冷的蓝色，而所有背景和灰色都使用完全中性的灰，界面有时会产生轻微的割裂感。&lt;/p&gt;
&lt;p&gt;OKRamp 会生成一条低彩度中性色阶，让它继承品牌色的冷暖倾向：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;neutral.C[i]
  = min(tintStrength, seed.C × 0.18)
  × neutralFactor[i]

默认 tintStrength = 0.025
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;中性色有独立的 10 阶和 14 阶明度曲线。彩度在中段略高，在极浅与极深端收敛。这样，它仍然是一套能承载大面积背景和正文的“灰”，只是与品牌色处在同一种气氛里。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;当输入本身接近无彩色时，结果也会自然回到普通灰阶。算法不会凭空为纯黑或纯白发明一个色相。&lt;/p&gt;
&lt;h3&gt;深色模式&lt;/h3&gt;
&lt;p&gt;深色模式并不是把浅色色阶倒过来。&lt;/p&gt;
&lt;p&gt;在深色界面里，页面背景来自中性色深端，容器和浮层逐级提亮；文字从浅端选择。品牌默认色会比浅色主题更亮，Hover 继续提亮，Active 相对压暗。按钮上的前景色也会重新从黑白中选择，而不是沿用浅色主题的结果。&lt;/p&gt;
&lt;p&gt;浅色与深色主题目前共享同一套基础品牌色阶，再分别映射语义角色；项目尚未为深色模式生成一条完全独立的品牌曲线。&lt;/p&gt;
&lt;h3&gt;对比度&lt;/h3&gt;
&lt;p&gt;主题角色建立后，引擎会检查预定义的前景与背景组合。默认目标是普通文字 &lt;code&gt;4.5:1&lt;/code&gt;、重要非文本元素 &lt;code&gt;3:1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它提供三种策略：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;report&lt;/code&gt;：保留原结果，只报告未达到目标的组合。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;adjust&lt;/code&gt;：在已有品牌或中性色阶中寻找满足目标、且与原角色最接近的候选色。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;strict&lt;/code&gt;：不调整，只要存在失败项便抛出结构化错误。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些检查覆盖引擎列出的语义组合。它们能帮助主题映射避免明显问题，但不等同于整个页面、所有字号和全部组件状态都获得了可访问性认证。&lt;/p&gt;
&lt;h2&gt;Playground&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://okramp.seeridia.top/&quot;&gt;https://okramp.seeridia.top&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;我同时为 OKRamp 做了一个 React + TDesign 的体验站。&lt;/p&gt;
&lt;p&gt;工作台可以调整主色、策略、阶数、端点、色相偏移、中性色强度和对比度策略。每次修改都会同时更新色板、组件预览、语义 Token 与诊断。&lt;/p&gt;
&lt;p&gt;界面也能切换浅色和深色，让同一组结果直接进入按钮、卡片、文字和边框，而不是停留在十个色块里。&lt;/p&gt;
&lt;p&gt;“方案对比”页面还会并排展示 OKRamp、TDesign 主题生成器使用的 &lt;code&gt;tvision-color&lt;/code&gt;、&lt;code&gt;@ant-design/colors&lt;/code&gt;，以及 RGB、HSL、Lab 等插值结果。&lt;/p&gt;
&lt;p&gt;生成结果可以导出为 CSS、JSON 或 TypeScript，并能映射到 TDesign、Ant Design 和 shadcn/ui。核心算法与这些适配器彼此独立，因此项目也可以只安装实际需要的部分。&lt;/p&gt;
&lt;h2&gt;展示&lt;/h2&gt;
&lt;p&gt;最后，把几类输入放在一起看。下面所有 Case 都直接调用 &lt;code&gt;generateColorScale(seed)&lt;/code&gt;，使用默认的 &lt;code&gt;tonal&lt;/code&gt; 策略与十级曲线，没有为某个颜色单独调整参数。&lt;/p&gt;
&lt;h3&gt;常规主色&lt;/h3&gt;
&lt;p&gt;这三组颜色分别覆盖蓝、红、绿三个常见界面色相。观察重点是：浅端是否仍能看出所属色相，中段是否足够鲜明，以及进入深端后有没有迅速发灰。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;极端明度输入&lt;/h3&gt;
&lt;p&gt;最后两组故意选择接近白色和黑色的蓝。默认 &lt;code&gt;tonal&lt;/code&gt; 不会强行把输入固定在中间某一阶，而是提取它的色相与彩度倾向，重新展开完整明度曲线。因此这两组结果不会保证原始色值出现在色阶中，但仍应当形成可用于背景、交互状态和文字的十个层级。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;不同方案的对比&lt;/h3&gt;
&lt;p&gt;再用青色 &lt;code&gt;#0099B8&lt;/code&gt; 做一次横向对比。这里统一输入主色与十级数量，但保留每种算法自己的生成规则；对应的结果也可以在 &lt;a href=&quot;https://okramp.seeridia.top/?page=compare&amp;#x26;compare.seed=%230099B8&quot;&gt;Playground 方案对比&lt;/a&gt; 中交互查看。&lt;/p&gt;
&lt;p&gt;选取这个也能色确实是一个比较极端的例子：它接近 sRGB 边界。不同算法在浅端、中段和深端的处理方式都不一样。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;OKRamp 使用默认 &lt;code&gt;tonal&lt;/code&gt; 策略重新建立完整的明度与彩度曲线，因此输入色没有原样出现在结果中。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;TDesign 主题生成器使用的 &lt;code&gt;tvision-color&lt;/code&gt; 基于 HCT，并根据色相调整 Tone 与彩度曲线。这组结果的推荐主色位于第 5 阶，输入色同样不保证被原样保留。&lt;/p&gt;
&lt;p&gt;另外可以明显看到第 3 阶的颜色异常偏亮。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Ant Design 使用基于 HSV 的十级色板算法，并将输入主色固定在第 6 阶。它的前五阶向浅端扩展，后四阶向深端扩展，所以主色位置与另外几组并不相同。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/okramp&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;最后两组使用同一个简单条件：前七阶从白色插值到主色，后三阶从主色插值到黑色。HSL 的参数更容易理解，但明度与饱和度并不感知均匀；sRGB 视觉明度变化容易在深端聚集。&lt;/p&gt;
&lt;h2&gt;结束语&lt;/h2&gt;
&lt;p&gt;OKRamp 目前选择了一条偏工程化的路线：用 OKLCH 拆开问题，表达设计倾向，用不同策略公开取舍。&lt;/p&gt;
&lt;p&gt;当然也为其生态提供了适配，让它能直接进入各个设计系统中，各个组件库中，帮助设计师和开发者快速生成可用的主题。&lt;/p&gt;
&lt;p&gt;它仍然有继续改进的空间。不同色相目前共享基础曲线，sRGB 边界会改变局部彩度变化，比如 Tdesign 的 &lt;code&gt;tvision-color&lt;/code&gt; 对很多颜色都单独调整了彩度曲线。OKRamp 也可以在未来提供更多可选的明度与彩度曲线，或者允许用户自定义。&lt;/p&gt;
&lt;p&gt;在对任务的探索的过程中，也是对颜色空间、色彩感知和视觉实验有了更深入的理解。了解了不同的颜色空间如何影响颜色的感知，以及如何在设计中应用这些知识来创建更符合人眼感受的颜色方案。&lt;/p&gt;
&lt;p&gt;但作为这个任务的回答，我想我的这些工作和这个文档已经不仅能“变出”十个颜色，也能展示这十个颜色为什么这样变化，以及它们如何真正走进一个界面。&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.nTjf1ZmS.webp"/><category>design</category><category>typescript</category><category>color</category><category>tdesign</category></item><item><title>用 Cloudflare One 与 EasyTier 构建内外双路径的个人服务网络</title><link>https://blog.seeridia.top/blog/cloudflare-network</link><guid isPermaLink="true">https://blog.seeridia.top/blog/cloudflare-network</guid><description>个人内网服务管理</description><pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;自用的自托管服务越来越多，像是 grafana、n8n 等等各种的管理面板，那如何在保证安全性的同时，提高自己的使用体验？&lt;/p&gt;
&lt;p&gt;这些服务通常只需要我本人访问，并不适合直接暴露在互联网上。但完全限制在家庭局域网或者 VPN 内，又会在临时设备、公司电脑或无法接入 VPN 的环境中带来不便。&lt;/p&gt;
&lt;p&gt;所以我将需求确定：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在接入 VPN 后，访问流量直接通过虚拟内网到达服务器；&lt;/li&gt;
&lt;li&gt;在未接入 VPN 时，经过一层登录鉴权后访问。如果可以，这边能较为自由的控制用户策略，比如临时给他人授权。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;由此我也确定了采用了由 Easytier 和 Cloudflare 构成的混合方案：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;连接 EasyTier 时，访问流量直接通过虚拟内网到达服务器，不经过 Cloudflare；&lt;/li&gt;
&lt;li&gt;未连接 EasyTier 时，使用同一个域名访问，先经过 Cloudflare Access 登录鉴权，再通过 Tunnel 回源。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;架构设计&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart TD
    A[&quot;访问 grafana.seeridia.link&quot;] --&gt; B{&quot;是否连接 EasyTier&quot;}
    B --&gt;|&quot;是&quot;| C[&quot;DNS 本地映射&quot;]
    C --&gt; D[&quot;10.126.0.1:443&quot;]
    D --&gt; E[&quot;1Panel / HTTPS&quot;]
    E --&gt; F[&quot;Grafana&amp;#x3C;br/&gt;127.0.0.1:3011&quot;]
    B --&gt;|&quot;否&quot;| G[&quot;Cloudflare DNS&quot;]
    G --&gt; H[&quot;Cloudflare Access&quot;]
    H --&gt; I[&quot;身份认证与允许策略&quot;]
    I --&gt; J[&quot;Cloudflare Tunnel&quot;]
    J --&gt; K[&quot;Seeridia-Net 连接器&quot;]
    K --&gt; F
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用者不需要关心当前走的是哪条路径，只需要始终打开：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;https://grafana.seeridia.link
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;域名的解析结果决定了后续流量是通过 EasyTier 直达服务器，还是进入 Cloudflare 的身份认证流程。&lt;/p&gt;
&lt;h2&gt;用户端 - Easytier 侧&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/EasyTier/EasyTier&quot;&gt;EasyTier&lt;/a&gt; 负责建立设备和服务器之间的加密虚拟网络，类似于常见的 Tailscale 等 VPN，这些都可以的，其实都差不了太多，不过 Easytier 往往拥有更为激进的策略，这边不展开讲其相关配置。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/92cbad538e1f33067990e13620f9eb31.CZRNzKup_2qttB.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;其实有了 Easytier 我们就可以直接通过内网去访问相关的服务而无需打开相关端口的安全组等等，到这一步就能满足大多数人的需求了，不过我们的最终目标还需要比如访问 &lt;a href=&quot;http://grafana.seeridia.link/&quot;&gt;grafana.seeridia.link&lt;/a&gt; 能根据我们自身环境，后续流量是通过 EasyTier 直达服务器，还是进入 Cloudflare 的身份认证流程。&lt;/p&gt;
&lt;p&gt;在用户侧，我们要解决的是对这个 &lt;a href=&quot;http://grafana.seeridia.link/&quot;&gt;seeridia.link&lt;/a&gt; 域名我们应进行 DNS 映射，相关域名直接解析到 EasyTier 地址，我们可以有多种方式去配置 DNS，比如 macOS 可以通过 &lt;strong&gt;&lt;code&gt;/etc/resolver&lt;/code&gt;&lt;/strong&gt; 实现“不同域名走不同 DNS”&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://support.apple.com/zh-cn/guide/mac-help/mh14127/mac&quot;&gt;bookmark&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;不过我自己有 Surge，就也直接使用 Surge 了&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/9850809c26b68235f731cdcf870441db.DpSSCL1q_Z1N5i6s.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;Cloudflare One&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;我都以自己的&lt;code&gt;seeridia.link&lt;/code&gt; 为例，首先需要将域名托管在 Cloudflare。我们要做的是：当设备不使用 Surge 的本地 DNS 映射时，&lt;code&gt;grafana.seeridia.link&lt;/code&gt; 会按照 Cloudflare 公网 DNS 的配置进入 Cloudflare 网络。&lt;/p&gt;
&lt;h3&gt;Cloudflare Tunnel&lt;/h3&gt;
&lt;p&gt;Cloudflare Tunnel 是用以将服务器接入 Cloudflare&lt;/p&gt;
&lt;p&gt;在 Cloudflare Tunnel 中创建了一个名为 &lt;code&gt;Seeridia-Net&lt;/code&gt; 的隧道，将服务器接入该隧道，该步骤按照 Cloudflare 的指引即可&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/29da3ffd865897a8d588681e618ae124.CtBvluqk_1f4h3d.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;接着可以编辑「已发布应用程序路由」&lt;/p&gt;
&lt;p&gt;这边放上需要接入 Cloudflare Access 的全部服务&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/8d91bd13e89a4da60a8b99d9f022b5b8.D8E30Y-__WazFz.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;这边不需要再配置 DNS，Cloudflare 会自动帮你配好&lt;/p&gt;
&lt;h3&gt;标识提供程序&lt;/h3&gt;
&lt;p&gt;集成→标识提供程序&lt;/p&gt;
&lt;p&gt;用于配置提供访问网站的登录方式，这边的 One-time PIN 即发邮箱验证码，是默认免费提供的。当然你可以自己配置 Github、Google 等等的登录方式&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/af095f7d6be32cea7eedd51c34589865.beo6t3tR_MnK37.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;h3&gt;访问策略&lt;/h3&gt;
&lt;p&gt;访问控制→策略&lt;/p&gt;
&lt;p&gt;用于配置哪些用户可以访问我们的服务，并且也可以配置多个不同的访问策略面向不同的服务。这边主要就是验证上一步回调或验证的邮箱。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/8aad8b9d7dff46b3f1467c6f3c12734a.CaSRYYs1_Z1Pu1a5.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;h3&gt;应用程序&lt;/h3&gt;
&lt;p&gt;访问控制→应用程序&lt;/p&gt;
&lt;p&gt;这边主要是将应用程序，即在刚刚配置 Tunnel 那边的应用程序加入 Cloudflare One&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/554912d6b70a1895c56ffddc61dd2c20.Cg3z2Y21_Z1c46HA.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/3f8130b905054bd309ac8247b13e74a6.ND6q6TCj_ZqOrwc.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;h2&gt;服务器反代&lt;/h2&gt;
&lt;p&gt;正常部署反代即可。&lt;/p&gt;
&lt;p&gt;我自己用的是可视化面板的 1Panel，其他的也都差不多。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这套架构的核心并不是简单地把两个组网工具叠在一起，而是为同一个服务设计两种不同的访问方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对已经加入可信内网的设备，提供直接、低依赖的 EasyTier 访问路径；&lt;/li&gt;
&lt;li&gt;对没有加入内网的设备，提供由 Cloudflare Access 保护的浏览器访问路径。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对我来说，这套方案的价值在于：它既没有要求所有临时设备都安装 VPN，也没有为了方便而直接把私人服务暴露到公网；同时，当设备已经位于自己的 EasyTier 网络中时，又不需要让访问流量无意义地绕行 Cloudflare。&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.SIWjkviN.webp"/><category>website</category></item><item><title>Vitepress 站点提供下载 PDF 的方式</title><link>https://blog.seeridia.top/blog/wiki-download</link><guid isPermaLink="true">https://blog.seeridia.top/blog/wiki-download</guid><description>对文档站点使用 VitePress 构建时，如何为用户提供下载 PDF 版本的文档。</description><pubDate>Tue, 20 Jan 2026 18:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我有另一个关于 &lt;a href=&quot;https://github.com/Seeridia/Chemistry-Note&quot;&gt;化学笔记的项目&lt;/a&gt;，它使用 VitePress 构建文档站点。&lt;/p&gt;
&lt;p&gt;但其实这个项目面向的是高中生，他们更习惯于离线阅读资料（没得手机）。在笔者自己高中的时候，这个就是自己手动去一个个用 Typora 导出 PDF，然后再打包发给同学。一来是麻烦，二来是没办法及时更新。&lt;/p&gt;
&lt;p&gt;现在最近也闲了，也发现当时的这个项目现在看的人突然多了起来，所以一方面用 VitePress 构建文档站点，另一方面也想给用户提供一个下载 PDF 的方式。(其实也不仅局限于 vitepress)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/preview.D32OMwOb_Z29gpqy.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;所以我想着去彻底解决这个问题——实现在每次我更新文档站点的时候，顺便也能生成一个 PDF 版本的文档的自动化流程。&lt;/p&gt;
&lt;h2&gt;总体思路&lt;/h2&gt;
&lt;p&gt;思路其实很简单，VitePress 本身虽然并不支持直接导出 PDF，但是其构建产物不就是 HTML 页面吗？那我们完全可以使用一个工具，把这些 HTML 页面转换成 PDF。&lt;/p&gt;
&lt;p&gt;关于 PDF 的问题解决了，另一个就是关于下载的问题。由于用户主要都是国内的高中生，速度需要有保证，所以我选择把生成的 PDF 文件放在 &lt;a href=&quot;https://cnb.cool/&quot;&gt;cnb.cool&lt;/a&gt; 上面，这样用户可以直接通过 cnb 提供的文件下载链接来下载最新的 PDF 文件。&lt;/p&gt;
&lt;p&gt;（实际上是看了一些软件的镜像下载就放在 cnb 上，看着挺不错的，也就这样用了）&lt;/p&gt;
&lt;h2&gt;清理页面样式&lt;/h2&gt;
&lt;p&gt;首先是清理一下页面媒体查询在打印时的表现。&lt;/p&gt;
&lt;p&gt;媒体查询这个大家都蛮熟的了，这边放上一个调试方式（噗嗤，以前我还真是傻傻的去 Ctrl + P 预览打印效果，后来才知道有这个东西）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/debug.BP8SJ6uZ_1fptPh.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;好在 VitePress 本身也蛮打印友好的，稍微调整了一下隐藏一些不必要的元素（比如导航栏、侧边栏、页脚等）就好了。对我来说需求就这些，其实也可以在页脚加上一些只在打印时候显示的信息，比如版权页面等，都可以用媒体查询来实现。&lt;/p&gt;
&lt;h2&gt;判断需要导出的页面&lt;/h2&gt;
&lt;p&gt;其实主要是没法每次都全量导出所有页面的 PDF，毕竟文档站点页面多了之后，生成 PDF 的时间会很长。所以我就想着能不能只导出改动过的页面。&lt;/p&gt;
&lt;p&gt;下面直接贴代码，主要就是用 git diff 来判断哪些页面改动了，然后只导出这些页面。&lt;/p&gt;
&lt;p&gt;同时也对一些“全局变更”做了判断，比如说侧边栏配置、主题配置、公共资源等，只要这些变更了，就直接全量导出 PDF。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// scripts/changed-pages.js

import { execFileSync } from &apos;child_process&apos;
import fs from &apos;fs&apos;
import path from &apos;path&apos;
import url from &apos;url&apos;

const __dirname = path.dirname(url.fileURLToPath(import.meta.url))

const args = process.argv.slice(2)
const getArgValue = (flag) =&gt; {
    const index = args.indexOf(flag)
    if (index === -1) {
        return null
    }
    return args[index + 1] || null
}

const base = getArgValue(&apos;--base&apos;) || process.env.GIT_BASE
const head = getArgValue(&apos;--head&apos;) || process.env.GIT_HEAD || &apos;HEAD&apos;
const outDirInput = getArgValue(&apos;--out-dir&apos;)
const outDir = outDirInput
    ? path.resolve(process.cwd(), outDirInput)
    : path.resolve(__dirname, &apos;../.github/changed-pages&apos;)

// 全 0 的 SHA 表示“无基准”，需要全量导出
const zeroSha = /^0+$/
// 判断是否属于需要全量导出的“全局变更”
const isGlobalChange = (filePath) =&gt; {
    if (!filePath) {
        return false
    }
    const normalized = filePath.replace(/\\/g, &apos;/&apos;)
    // 如果是下面这些路径的变更，则直接视为全局变更
    return (
        normalized.startsWith(&apos;.vitepress/&apos;) ||
        normalized.startsWith(&apos;public/&apos;) ||
        normalized.startsWith(&apos;data/&apos;) ||
        normalized === &apos;package.json&apos; ||
        normalized === &apos;package-lock.json&apos; ||
        normalized === &apos;bun.lock&apos;
    )
}

// 将 .md 路径映射为站点输出的 .html 路径
const mdToHtml = (filePath) =&gt; {
    if (!filePath) {
        return null
    }
    const normalized = filePath.replace(/\\/g, &apos;/&apos;)
    if (!/\.md$/i.test(normalized)) {
        return null
    }
    const dir = path.posix.dirname(normalized)
    const baseName = path.posix.basename(normalized)
    const lower = baseName.toLowerCase()
    const targetName =
        lower === &apos;readme.md&apos; || lower === &apos;index.md&apos;
            ? &apos;index.html&apos;
            : baseName.replace(/\.md$/i, &apos;.html&apos;)
    return dir === &apos;.&apos; ? targetName : `${dir}/${targetName}`
}

const outputs = {
    forceAll: false,       // 是否需要全量导出
    changed: new Set(),    // 变更的页面列表
    deleted: new Set()     // 删除的页面列表
}

// base 不存在或是全 0，则直接全量
if (!base || zeroSha.test(base)) {
    outputs.forceAll = true
} else {
    let diffOutput = &apos;&apos;
    try {
        // 使用 git diff 获取变更文件列表（含重命名）
        diffOutput = execFileSync(&apos;git&apos;, [&apos;diff&apos;, &apos;--name-status&apos;, &apos;-z&apos;, `${base}..${head}`], {
            encoding: &apos;utf8&apos;
        }).trim()
    } catch (error) {
        // 失败时兜底为全量
        outputs.forceAll = true
    }

    if (!outputs.forceAll &amp;#x26;&amp;#x26; diffOutput) {
        // -z 模式以 \0 分隔，便于处理包含空格的路径
        const parts = diffOutput.split(&apos;\0&apos;).filter(Boolean)
        for (let index = 0; index &amp;#x3C; parts.length; index += 1) {
            const status = parts[index]
            if (!status) {
                continue
            }
            // 处理重命名：Rxxx 旧路径 新路径
            if (status.startsWith(&apos;R&apos;)) {
                const oldPath = parts[index + 1]
                const newPath = parts[index + 2]
                index += 2
                if (isGlobalChange(oldPath) || isGlobalChange(newPath)) {
                    outputs.forceAll = true
                }
                // 旧路径视为删除，新路径视为新增
                const deleted = mdToHtml(oldPath)
                const added = mdToHtml(newPath)
                if (deleted) {
                    outputs.deleted.add(deleted)
                }
                if (added) {
                    outputs.changed.add(added)
                }
                continue
            }

            const filePath = parts[index + 1]
            index += 1
            if (isGlobalChange(filePath)) {
                outputs.forceAll = true
            }

            // 只跟踪 Markdown 的变更
            const htmlPath = mdToHtml(filePath)
            if (!htmlPath) {
                continue
            }
            if (status === &apos;D&apos;) {
                outputs.deleted.add(htmlPath)
            } else {
                outputs.changed.add(htmlPath)
            }
        }
    }
}

fs.mkdirSync(outDir, { recursive: true })

const changedList = outputs.forceAll ? [] : Array.from(outputs.changed).sort()
const deletedList = Array.from(outputs.deleted).sort()

// 写入结果文件，供后续工作流读取
fs.writeFileSync(path.join(outDir, &apos;export-all.txt&apos;), outputs.forceAll ? &apos;true&apos; : &apos;false&apos;)
fs.writeFileSync(path.join(outDir, &apos;changed-pages.txt&apos;), changedList.join(&apos;\n&apos;))
fs.writeFileSync(path.join(outDir, &apos;deleted-pages.txt&apos;), deletedList.join(&apos;\n&apos;))

// 控制台输出简要统计
console.log(
    JSON.stringify({
        forceAll: outputs.forceAll,
        changed: changedList.length,
        deleted: deletedList.length
    })
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;导出 PDF&lt;/h2&gt;
&lt;p&gt;这边我使用的是 &lt;a href=&quot;https://playwright.dev/&quot;&gt;Playwright&lt;/a&gt; 来做这个工作，因为它本身支持无头浏览器操作，并且可以直接导出 PDF。&lt;/p&gt;
&lt;p&gt;读取刚刚生成的变更页面列表，然后一个个打开页面，直接调用 &lt;code&gt;page.pdf()&lt;/code&gt; 方法导出 PDF 即可。唯一注意的是 CJK 字体的问题，需要注入一些字体样式，确保中文能正确显示。（这边在后续的 GitHub Actions 里也需要安装了 Noto Sans CJK 字体）&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// scripts/export-pdf.js

import { chromium } from &apos;playwright&apos;
import fg from &apos;fast-glob&apos;
import http from &apos;http&apos;
import fs from &apos;fs&apos;
import path from &apos;path&apos;
import url from &apos;url&apos;

const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const distDir = path.resolve(__dirname, &apos;../.vitepress/dist&apos;)

const args = process.argv.slice(2)
const listIndex = args.indexOf(&apos;--list&apos;)
const outIndex = args.indexOf(&apos;--out-dir&apos;)
const listPath = listIndex &gt;= 0 ? args[listIndex + 1] : null
const outDirArg = outIndex &gt;= 0 ? args[outIndex + 1] : null
if (listIndex &gt;= 0 &amp;#x26;&amp;#x26; !listPath) {
    throw new Error(&apos;Missing value for --list&apos;)
}
if (outIndex &gt;= 0 &amp;#x26;&amp;#x26; !outDirArg) {
    throw new Error(&apos;Missing value for --out-dir&apos;)
}
const outDir = outDirArg
    ? path.resolve(process.cwd(), outDirArg)
    : path.resolve(__dirname, &apos;../pdf&apos;)

fs.mkdirSync(outDir, { recursive: true })

const mimeTypes = {
    &apos;.css&apos;: &apos;text/css; charset=utf-8&apos;,
    &apos;.gif&apos;: &apos;image/gif&apos;,
    &apos;.html&apos;: &apos;text/html; charset=utf-8&apos;,
    &apos;.jpeg&apos;: &apos;image/jpeg&apos;,
    &apos;.jpg&apos;: &apos;image/jpeg&apos;,
    &apos;.js&apos;: &apos;application/javascript; charset=utf-8&apos;,
    &apos;.json&apos;: &apos;application/json; charset=utf-8&apos;,
    &apos;.png&apos;: &apos;image/png&apos;,
    &apos;.svg&apos;: &apos;image/svg+xml&apos;,
    &apos;.webp&apos;: &apos;image/webp&apos;,
    &apos;.woff&apos;: &apos;font/woff&apos;,
    &apos;.woff2&apos;: &apos;font/woff2&apos;
}

// 用本地 HTTP 服务替代 file:// 访问，确保 CSS/JS/图片/字体等资源按正常 URL 规则加载
// 同时避免浏览器对 file:// 的安全限制导致资源无法读取
const server = http.createServer((req, res) =&gt; {
    if (!req.url) {
        res.writeHead(400)
        res.end(&apos;Bad Request&apos;)
        return
    }

    // 去掉查询参数后再做 decode，防止路径中包含中文或空格时无法访问
    const decodedPath = decodeURIComponent(req.url.split(&apos;?&apos;)[0])
    const safePath = decodedPath.replace(/^\/+/, &apos;&apos;)
    const filePath = path.resolve(distDir, safePath || &apos;index.html&apos;)

    if (!filePath.startsWith(distDir)) {
        res.writeHead(403)
        res.end(&apos;Forbidden&apos;)
        return
    }

    fs.stat(filePath, (err, stats) =&gt; {
        if (err || !stats.isFile()) {
            res.writeHead(404)
            res.end(&apos;Not Found&apos;)
            return
        }

        const ext = path.extname(filePath).toLowerCase()
        const contentType = mimeTypes[ext] || &apos;application/octet-stream&apos;
        res.writeHead(200, { &apos;Content-Type&apos;: contentType })
        fs.createReadStream(filePath).pipe(res)
    })
})

let files = []
if (listPath) {
    const raw = fs.readFileSync(listPath, &apos;utf8&apos;)
    files = raw
        .split(&apos;\n&apos;)
        .map((line) =&gt; line.trim())
        .filter((line) =&gt; line &amp;#x26;&amp;#x26; line !== &apos;404.html&apos;)
    files = Array.from(new Set(files))
    files = files.filter((file) =&gt; fs.existsSync(path.join(distDir, file)))
} else {
    files = await fg(&apos;**/*.html&apos;, {
        cwd: distDir,
        ignore: [&apos;404.html&apos;]
    })
}

if (files.length === 0) {
    console.log(&apos;No pages to export.&apos;)
    process.exit(0)
}

const serverPort = await new Promise((resolve) =&gt; {
    server.listen(0, &apos;127.0.0.1&apos;, () =&gt; resolve(server.address().port))
})

const browser = await chromium.launch()
const page = await browser.newPage()
// 注入字体样式，确保中文能好好显示
await page.addStyleTag({
    content:
        &apos;html, body { font-family: &quot;Noto Sans CJK SC&quot;,&quot;Noto Sans SC&quot;,&quot;Source Han Sans SC&quot;,&quot;Microsoft YaHei&quot;,&quot;PingFang SC&quot;,sans-serif !important; }&apos;
})

for (const file of files) {
    const inputPath = path.join(distDir, file)
    const outputPath = path.join(outDir, file.replace(/\.html$/, &apos;.pdf&apos;))

    fs.mkdirSync(path.dirname(outputPath), { recursive: true })

    const urlPath = encodeURI(file.replace(/\\/g, &apos;/&apos;))
    const fileUrl = `http://127.0.0.1:${serverPort}/${urlPath}`

    console.log(&apos;Exporting:&apos;, file)

    await page.goto(fileUrl, { waitUntil: &apos;networkidle&apos; })
    await page.evaluate(() =&gt; (document.fonts ? document.fonts.ready : null))

    await page.pdf({
        path: outputPath,
        format: &apos;A4&apos;,
        printBackground: true,
        margin: {
            top: &apos;15mm&apos;,
            bottom: &apos;15mm&apos;,
            left: &apos;15mm&apos;,
            right: &apos;15mm&apos;
        }
    })
}

await browser.close()
await new Promise((resolve) =&gt; server.close(resolve))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Github Actions 集成&lt;/h2&gt;
&lt;p&gt;到这边我个人的方案是：将上一步已经导出的 PDF 文件 push 到一个单独的仓库上，并且我选择放在 cnb 上面，这样国内用户可以直接通过 cnb 提供的文件下载链接来下载最新的 PDF 文件。&lt;/p&gt;
&lt;p&gt;这边我直接是将每次构建的新文件都放新的分支上，然后强制改名覆盖主分支，这样 cnb 上就只有一次 commit 的历史记录，节省空间和拉取速度。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Deploy to Production Server

on:
  push:
    branches:
      - master

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      # 第一步：迁出代码
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # 第二步：设置 Bun 环境
      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      # 第三步：安装依赖
      - name: Install Dependencies
        run: bun install

      # 第四步：打包项目
      - name: Build Project
        run: bun run docs:build

      # 第五步：检测变更页面
      - name: Detect changed pages
        id: changed_pages
        run: |
          bun scripts/changed-pages.js --base &quot;${{ github.event.before }}&quot; --head &quot;${{ github.sha }}&quot; --out-dir .github/changed-pages
          export_all=$(cat .github/changed-pages/export-all.txt)
          changed_count=$(grep -c . .github/changed-pages/changed-pages.txt || true)
          deleted_count=$(grep -c . .github/changed-pages/deleted-pages.txt || true)
          echo &quot;export_all=$export_all&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;changed_count=$changed_count&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;deleted_count=$deleted_count&quot; &gt;&gt; $GITHUB_OUTPUT

      # 第六步：上传构建产物供 PDF 导出使用
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: site-dist
          path: |
            .vitepress/dist
            .github/changed-pages

      # 第七步：部署到服务器（略）

  export-pdfs:
    needs: build-and-deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: site-dist
          path: .

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install Dependencies
        run: bun install

      # 需要安装 CJK 字体以确保 PDF 导出时中文显示正确
      - name: Install CJK fonts
        run: |
          sudo apt-get update
          sudo apt-get install -y fonts-noto-cjk

      # 读取变更页面信息
      - name: Read changed pages info
        id: changed_pages
        run: |
          export_all=$(cat .github/changed-pages/export-all.txt 2&gt;/dev/null || echo &quot;false&quot;)
          changed_count=$(grep -c . .github/changed-pages/changed-pages.txt 2&gt;/dev/null || true)
          deleted_count=$(grep -c . .github/changed-pages/deleted-pages.txt 2&gt;/dev/null || true)
          echo &quot;export_all=$export_all&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;changed_count=$changed_count&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;deleted_count=$deleted_count&quot; &gt;&gt; $GITHUB_OUTPUT

      # 克隆 PDF 仓库（只需要 clone 1层，不然后面很大）
      - name: Clone CNB PDF Repository
        if: steps.changed_pages.outputs.export_all == &apos;true&apos; || steps.changed_pages.outputs.changed_count != &apos;0&apos; || steps.changed_pages.outputs.deleted_count != &apos;0&apos;
        run: |
          git clone --depth 1 https://cnb:${{ secrets.CNB_TOKEN }}@cnb.cool/Seeridia/Chemistry-Note-File.git pdf-repo

      - name: Install Playwright Browsers
        if: steps.changed_pages.outputs.export_all == &apos;true&apos; || steps.changed_pages.outputs.changed_count != &apos;0&apos;
        run: npx playwright install --with-deps chromium

      - name: Export PDFs (all)
        if: steps.changed_pages.outputs.export_all == &apos;true&apos;
        run: bun scripts/export-pdf.js --out-dir pdf-repo

      - name: Export PDFs (changed)
        if: steps.changed_pages.outputs.export_all != &apos;true&apos; &amp;#x26;&amp;#x26; steps.changed_pages.outputs.changed_count != &apos;0&apos;
        run: bun scripts/export-pdf.js --list .github/changed-pages/changed-pages.txt --out-dir pdf-repo

      - name: Remove deleted PDFs
        if: steps.changed_pages.outputs.deleted_count != &apos;0&apos;
        run: |
          while IFS= read -r file; do
            [ -z &quot;$file&quot; ] &amp;#x26;&amp;#x26; continue
            pdf=&quot;${file%.html}.pdf&quot;
            rm -f &quot;pdf-repo/$pdf&quot;
          done &amp;#x3C; .github/changed-pages/deleted-pages.txt

      - name: Push PDF updates
        if: steps.changed_pages.outputs.export_all == &apos;true&apos; || steps.changed_pages.outputs.changed_count != &apos;0&apos; || steps.changed_pages.outputs.deleted_count != &apos;0&apos;
        run: |
          cd pdf-repo
          git config user.name &quot;github-actions[bot]&quot;
          git config user.email &quot;github-actions[bot]@users.noreply.github.com&quot;
          if [ -z &quot;$(git status --porcelain)&quot; ]; then
            echo &quot;No PDF changes to push.&quot;
            exit 0
          fi
          git checkout --orphan temp-sync
          git add -A
          git commit -m &quot;Sync PDFs from ${{ github.sha }}&quot;
          git branch -M main
          git push --force origin main
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;vitepress 配合&lt;/h2&gt;
&lt;p&gt;最后在 VitePress 站点里加一个下载链接就好了，这一部分可以去 &lt;a href=&quot;https://vitepress.dev/zh/reference/default-theme-nav#nav&quot;&gt;vitepress 官方文档&lt;/a&gt;里看看相关内容&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-vue&quot;&gt;&amp;#x3C;script setup lang=&quot;ts&quot;&gt;
import { computed } from &quot;vue&quot;;
import { useData } from &quot;vitepress&quot;;

const { page } = useData();

const PDF_BASE_URL =
  &quot;https://cnb.cool/Seeridia/Chemistry-Note-File/-/git/raw/main/&quot;;

// 生成当前页面对应的 PDF 下载链接
const pdfUrl = computed(() =&gt; {
  const filePath = page.value.filePath ?? &quot;&quot;;
  if (!filePath.endsWith(&quot;.md&quot;)) return &quot;&quot;;
  const pdfPath = filePath.replace(/\.md$/i, &quot;.pdf&quot;);
  return `${PDF_BASE_URL}${encodeURI(pdfPath)}?download=true`;
});

// 只有在文档页面才显示下载 PDF 按钮
const isDocPage = computed(
  () =&gt; (page.value.frontmatter?.layout ?? &quot;doc&quot;) === &quot;doc&quot;,
);
const shouldShow = computed(() =&gt; isDocPage.value &amp;#x26;&amp;#x26; pdfUrl.value);

&amp;#x3C;/script&gt;

&amp;#x3C;template&gt;
  &amp;#x3C;div class=&quot;CCPdfDownloadButton&quot;&gt;
    &amp;#x3C;a
      v-if=&quot;shouldShow&quot;
      class=&quot;CCPdfDownloadButtonBtn&quot;
      :href=&quot;pdfUrl&quot;
      target=&quot;_blank&quot;
      rel=&quot;noopener&quot;
    &gt;
      下载 PDF
    &amp;#x3C;/a&gt;
  &amp;#x3C;/div&gt;
&amp;#x3C;/template&gt;

&amp;#x3C;style scoped&gt;
.CCPdfDownloadButton {
  display: flex;
  align-items: center;
}

.CCPdfDownloadButtonBtn {
  padding: 8px 14px;
  border-radius: 999px;
  border: 1px solid var(--vp-c-divider);
  background: var(--vp-c-bg-soft);
  color: var(--vp-c-text-1);
  font-size: 14px;
  line-height: 1;
  transition:
    border-color 0.25s,
    background-color 0.25s,
    color 0.25s;
  cursor: pointer;
}

.CCPdfDownloadButtonBtn:hover {
  border-color: var(--vp-c-brand-1);
  background: var(--vp-c-bg-soft-hover);
  color: var(--vp-c-brand-1);
}
&amp;#x3C;/style&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// .vitepress/theme/index.ts

import DefaultTheme from &quot;vitepress/theme&quot;;
import type { App } from &quot;vue&quot;;
import layout from &quot;./layout.vue&quot;;
import &quot;./custom.css&quot;;

import CCPdfDownloadButton from &quot;./components/CCPdfDownloadButton.vue&quot;;

export default {
  extends: DefaultTheme,
  Layout: layout,
  enhanceApp({ app }: { app: App }) {
    app.component(&quot;CCPdfDownloadButton&quot;, CCPdfDownloadButton);
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另外导航栏这边的组件只能在桌面端显示，移动端是放在二级菜单里面，我个人是多处理了一个用插槽来实现移动端显示下载按钮&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-vue&quot;&gt;&amp;#x3C;script setup&gt;
import DefaultTheme from &quot;vitepress/theme&quot;;
import PdfDownloadButton from &quot;./components/CCPdfDownloadButton.vue&quot;;

const { Layout } = DefaultTheme;
&amp;#x3C;/script&gt;

&amp;#x3C;template&gt;
  &amp;#x3C;Layout&gt;
    &amp;#x3C;!-- 只有在移动端显示该 PDF 下载按钮，桌面端的按钮在了导航栏里 --&gt;
    &amp;#x3C;template #nav-bar-content-before&gt;
      &amp;#x3C;div class=&quot;PdfDownloadButton&quot;&gt;
        &amp;#x3C;PdfDownloadButton /&gt;
      &amp;#x3C;/div&gt;
    &amp;#x3C;/template&gt;
  &amp;#x3C;/Layout&gt;
&amp;#x3C;/template&gt;

&amp;#x3C;style scoped&gt;
.PdfDownloadButton {
  display: none;
}

@media (max-width: 768px) {
  .PdfDownloadButton {
    display: block;
  }
}
&amp;#x3C;/style&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到此，我们实现了&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;PDF 自动构建，实时更新&lt;/li&gt;
&lt;li&gt;在 VitePress 站点提供下载链接&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;具体的实现可以看看原项目&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/wiki-download&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.BqqgECAj.webp"/><category>vitepress</category><category>wiki</category><category>github actions</category><category>ci/cd</category></item><item><title>text-autospace： CJK 字符间距控制</title><link>https://blog.seeridia.top/blog/text-autospace</link><guid isPermaLink="true">https://blog.seeridia.top/blog/text-autospace</guid><description>史诗级的中英文混排间距控制方式</description><pubDate>Thu, 25 Dec 2025 00:17:00 GMT</pubDate><content:encoded>&lt;p&gt;相关文档：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.mozilla.org/zh-CN/docs/Web/CSS/Reference/Properties/text-autospace&quot;&gt;text-autospace - CSS：层叠样式表 | MDN&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://caniuse.com/?search=text-autospace&quot;&gt;&quot;text-autospace&quot; | Can I use... Support tables for HTML5, CSS3, etc&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Baseline&lt;/strong&gt; 2025 支持 Chrome 140+&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;此功能可以用以实现中英文混排中的间距控制&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;text-autospace: normal;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;:root {
  /* 启用自动混排间距 */
  text-autospace: normal;
}

/* 兼容性判断 */
@supports (text-autospace: normal) {
  :root {
    text-autospace: normal;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;其实这边的兼容性判断可以不用的，因为旧浏览器会自动忽略这个属性&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Example&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/1.D-lpifo2_ZCvB3c.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;text-autospace: no-autospace;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/2.D2ZjT_kI_Z1mUf7t.webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;text-autospace: normal;&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;normal&lt;/code&gt;：创建默认行为，自动在 CJK 字符与非 CJK 字符之间以及标点符号周围添加间距。此值的效果等同于同时应用 &lt;code&gt;ideograph-alpha&lt;/code&gt; 和 &lt;code&gt;ideograph-numeric&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no-autospace&lt;/code&gt;：禁用 CJK 和非 CJK 字符间的自动间距行为。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ideograph-alpha&lt;/code&gt;：仅在表意文字（如片假名和汉字）与非表意字母（如拉丁字母）之间添加间距。不会在表意文字与非表意数字之间添加间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ideograph-numeric&lt;/code&gt;仅在表意文字（如片假名和汉字）与非表意数字（如拉丁数字）之间添加间距。不会在表意文字与非表意字母之间添加间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;punctuation&lt;/code&gt;根据特定语言的排版规范，在标点符号周围添加不可分割的间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;insert&lt;/code&gt;仅当表意文字与非表意文字之间不存在现有空格时，才添加指定的间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;replace&lt;/code&gt;将表意文字与非表意文字之间的现有间距（例如 &lt;a href=&quot;https://developer.mozilla.org/zh-CN/docs/Glossary/Whitespace&quot;&gt;U+0020&lt;/a&gt;）替换为指定的间距。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auto&lt;/code&gt;允许浏览器选择符合排版规范的间距。不同浏览器和平台间的间距可能存在差异。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;或许你看着上面的 &lt;code&gt;repalce&lt;/code&gt; 和&lt;code&gt;insert&lt;/code&gt; 更好用，因为实际上不少人（特别是像我们这样的开发者）现在都已经习惯在中英文之间加上一个空格了（甚至微信输入法有这个自动加空格的功能），所以这个属性显然更友好（因为能同时兼顾不同书写风格群体）&lt;/p&gt;
&lt;p&gt;但是！&lt;/p&gt;
&lt;p&gt;挺遗憾的，目前为止（December 24, 2025），只有 Firefox 支持了&lt;code&gt;insert&lt;/code&gt; ，而&lt;code&gt;replace&lt;/code&gt; 目前就是 0 支持。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;使用场景的一点想法？&lt;/h2&gt;
&lt;p&gt;其实主要并不知道是否是准确的，但是自己是这样想的：&lt;/p&gt;
&lt;p&gt;在展示文本等等的大部分场景中，这个&lt;code&gt;text-autospace: normal;&lt;/code&gt; 开起来确实是能提高整段话的顺眼程度，但是在&lt;strong&gt;编辑&lt;/strong&gt;的场景中，我个人倒是认为这个不能开，因为会误判——「这边是不是误打个空格？」&lt;/p&gt;
&lt;p&gt;不过这边也有一个可以说的，这个间距实际控制得很微妙，其实产生这个误判其实也不是很明显。比如你就回到刚刚上面的那个对比图，怎么说？&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;反正自己的东西也是放在，这种很稀碎的文章，我之后也放进来吧。并打上标签 &lt;code&gt;TIL&lt;/code&gt; (Today I Learned)。&lt;/p&gt;
&lt;p&gt;另，本次更新我也顺便把你现在看到的博客整体布局的 &lt;code&gt;body&lt;/code&gt; 标签里加上了 &lt;code&gt;text-autospace: normal;&lt;/code&gt;，所以你现在看到的博客正文也是开启了这个属性的。&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.IXhnlvnN.webp"/><category>website</category><category>css</category><category>til</category></item><item><title>我有特别的日程管理技巧</title><link>https://blog.seeridia.top/blog/schedule-management</link><guid isPermaLink="true">https://blog.seeridia.top/blog/schedule-management</guid><description>在大学生活中，最为重要的技能</description><pubDate>Thu, 20 Nov 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;没有之一，日程管理永远是大学生活中最重要的技能之一。&lt;/p&gt;
&lt;p&gt;或许如我刚进大学的时候，不做日程管理，或许也过得很好。要做什么，真就是用脑子想一想就能记住的。作业？截止前自然有人提醒；考试？考前一两周自然老师会反复强调。&lt;/p&gt;
&lt;p&gt;但是，随着时间的推移，任务越来越繁重，单靠记忆已经远远不够了。甚至是经常遇到，今天突然告知我下个月要做啥事，这天才能想得起来）&lt;/p&gt;
&lt;p&gt;而日程的管理，不仅仅解决的是“记住要做什么”的问题，更重要的是“合理安排任务”的问题。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/demonstration.BSgILJtI_Zu2VaL.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;最为基础的方式：使用日历&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;主包目前实际用的方式比较复杂了，在后面的章节里面，但是这个方式还是目前最推荐的，因为简单易行。也因此这边图片不多，主要是文字说明。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;虽说是最简单的，但也是目前最实用的方式。&lt;/p&gt;
&lt;p&gt;我更推荐就直接使用手机自带的日历应用，因为既然我们选择了这种方式，我们就要从简，不仅仅是初次配置的简化，更重要的是后续的使用简化。不能让日历的使用变成一件麻烦事，否则就失去了意义——你坚持不了多久。&lt;/p&gt;
&lt;h3&gt;日历的配置&lt;/h3&gt;
&lt;p&gt;我下面都以我自己的手机（小米家的 HyperOS 3）为例，来说说配置，这边仅供参考，其他手机的日历应用大同小异。&lt;/p&gt;
&lt;p&gt;一个是创建多个日历（日程集，或者说是日程分类），我当时的方案是只有两个：「课程表」和「任务清单」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;课程表&lt;/strong&gt;：我使用的是订阅的方式，比如福州大学的 西二 团队开发的福 uu 课程表，提供了日程订阅的功能，直接订阅就行了。这样就不需要手动添加上课时间了。&lt;/p&gt;
&lt;p&gt;对于其他学校的同学，如果学校没有提供类似的课程表订阅功能，可以考虑自己手动录入，或者不在日历里面做这个日程分类，用你们自家的课程表 App 或是小程序还是 Wakeup 之类的来查看上课时间。我自己的这个主要是为了能在一个表里面看到包含课程表在内的所有日程，方便我去安排，而非日历看一下，自己的 App 看一下，这不优雅～&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;任务清单&lt;/strong&gt;：则是我自己手动添加的，主要是把所有需要做的任务都添加进去。这里的任务包括作业、考试、活动、会议等等一切需要你去做的事情。事情可以无关大小，重要与否，甚至是提醒自己晚饭后要去拿个快递这种小事也可以添加进去。总之，凡是你需要记住的事情，都可以添加进去。&lt;/p&gt;
&lt;p&gt;因为我们在这个阶段的设计目标是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;提醒自己要做什么事&lt;/li&gt;
&lt;li&gt;自己要安排某一个事情的时候知道具体的时间有没有空&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以，这两个日历就足够了。&lt;/p&gt;
&lt;h3&gt;那我们该如何使用呢？&lt;/h3&gt;
&lt;p&gt;我们最重要的是保证你能把日程管理这个给坚持下来。所以，最重要的就是&lt;strong&gt;简单&lt;/strong&gt;。我就是直接「小爱同学，提醒我明天早上 8:00-9:00 做下高级微积分的作业」，这样，这个日程便添加进来了。你也可以直接在日历 App 里面添加，反正就是简单快捷。&lt;/p&gt;
&lt;p&gt;这边也可以给一些可以参考的日程提示（有哪些事情添加进去）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;赚德育分的任务，比如每天要我签到、让我每天拍个照&lt;/li&gt;
&lt;li&gt;课程作业的截止时间&lt;/li&gt;
&lt;li&gt;考试时间&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;再多一点：全平台同步的日历&lt;/h2&gt;
&lt;p&gt;比如我有多个设备，小米、华为、iPhone、Mac、Windows 电脑等等，这些设备上都需要查看日程，甚至或许有在 Google 日历上面使用的需求。我该如何做到呢？&lt;/p&gt;
&lt;p&gt;这个时候，我们就需要一个全平台同步的日历服务了。可以考虑使用 Google 日历、Outlook 日历等服务，它们都提供了跨平台的支持，可以在不同设备上查看和管理日程。&lt;/p&gt;
&lt;p&gt;这边并不是说你需要下载他们对应的 App，而是说你可以把你手机上面的日历和这些服务进行同步。这样，无论你在哪个设备上查看日程，都是一致的。&lt;/p&gt;
&lt;p&gt;而我最推荐的是 iCloud 日历服务，尤其是对于苹果设备用户来说，iCloud 日历的集成度非常高，使用体验也非常好。还有一个好处是，iCloud 可以在任何网络环境下运行，这对于国内用户来说是一个很大的优势。&lt;/p&gt;
&lt;h3&gt;iCloud 日历的配置&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;得先有一个 Apple ID，如果没有的话，自己去注册一个&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在 iCloud 上创建好自己的日历，至于日历里面的日历分类有哪些，请你参考上一节&lt;/p&gt;
&lt;p&gt;但是我没有 Apple 设备怎么办？&lt;/p&gt;
&lt;p&gt;iCloud 日历可以通过网页访问，所以你完全可以在 Windows 或者其他 Android 设备上使用 iCloud 日历。只需要在浏览器中登录你的 Apple ID，然后访问 iCloud.com，就可以使用日历功能了。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我们用苹果的 iCloud 日历服务，主要是因为它的稳定性和跨平台支持都非常好，而且在国内的访问速度也比较快。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;生成「App 专用密码」&lt;/p&gt;
&lt;p&gt;由于我们要在非苹果设备上使用 iCloud 日历，需要生成一个「App 专用密码」来进行登录。这个密码不同于你的 Apple ID 密码，专门用于第三方应用访问你的 iCloud 数据。类似于个人 token。&lt;/p&gt;
&lt;p&gt;生成方法如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;登录你的 Apple ID 账户页面：&lt;a href=&quot;https://appleid.apple.com/&quot;&gt;https://appleid.apple.com/&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在「安全性」部分，找到「生成密码」选项&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/apple-app-id.Q36X9RAg_Z2rNstP.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;按照提示生成一个新的「App 专用密码」并记住他，我们将在后续步骤中使用它。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在手机上添加 iCloud 账户&lt;/p&gt;
&lt;p&gt;以小米手机的日历为例：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;打开「日历」应用&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;右上角菜单进入「设置」页，再进入「日程账号管理」菜单&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;点击底部的「导入账号」&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;选择「Caldav 账号」&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;填入以下信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;账号：你的 Apple ID 邮箱&lt;/li&gt;
&lt;li&gt;密码：刚刚生成的 「App 专用密码」&lt;/li&gt;
&lt;li&gt;服务器地址：icloud.com&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;至此，你的手机日历就已经和 iCloud 日历同步了。你可以在手机上查看和管理你的日程，这些更改也会自动同步到 iCloud 上，使得所有订阅了该日历的设备都能看到最新的日程信息。&lt;/p&gt;
&lt;p&gt;这边也不仅仅是手机，其他设备也是类似的配置方式，只要支持 Caldav 协议的日历应用，都可以通过类似的方式添加 iCloud 账户，实现日程同步，比如：Google 日历、Outlook 日历、Notion 日历、飞书等等。&lt;/p&gt;
&lt;h2&gt;最后一点，将任务管理工具放进来&lt;/h2&gt;
&lt;p&gt;这个较为繁杂，如果你觉得太复杂，可以忽略这一部分，单纯使用日历来管理你的日程也是完全可以的。甚至如果你是刚开始做这样的计划的同学，我更推荐你忽略这一部分，先从日历开始，为了你能坚持下来。&lt;/p&gt;
&lt;h3&gt;有必要吗？&lt;/h3&gt;
&lt;p&gt;如果我们把任务管理工具也放进来，那就更完美了。&lt;/p&gt;
&lt;p&gt;有必要吗？我的想法是，有必要。我在日历中日程的管理，更多的是「时间」的管理，而任务管理工具更多的是「任务」的管理。两者结合起来，能够更好地帮助我们进行时间和任务的双重管理。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;我将饭后取快递这种提醒类的任务放在手机的日历中&lt;/li&gt;
&lt;li&gt;我将每天背单词、去做作业这种任务放在任务管理工具中&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;那我们该如何做呢？&lt;/h3&gt;
&lt;p&gt;如果你已经使用了一些任务管理工具，比如 Todoist、Microsoft To Do、TickTick 等等，这些工具大多也支持与日历同步。你可以将这些任务管理工具中的任务同步到你的日历中，这样就能在日历中看到所有的任务和日程，方便你进行整体的时间管理。&lt;/p&gt;
&lt;p&gt;我这边用的是 TickTick 嘀嗒清单，这边可以生成一个嘀嗒的任务日历订阅链接，然后把这个链接添加到你的日历中，就能实现任务和日程的统一管理。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/ticktick.D5JRhW67_Zs5vkA.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但是&lt;/strong&gt;：你或许会将这个订阅在 iCloud，然后想着「由于我手机上面已经订阅了 iCloud 日历，所以我只要在 iCloud 上面添加这个订阅就行了」，但是实际上并不能这样做，虽然你手机上也正确显示出了对应的日程集，但是会发现有错误，&lt;strong&gt;有些任务是直接没有显示出来的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;正确做法是，在手机上（或是你需要展示任务日历的设备上）直接添加这个订阅链接，这样才能正确显示所有的任务。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/subscription-method.BtIcWi0-_ULvYt.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/subscription-method-2.DMyn0O3D_ZATGN9.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;这边的左图是&lt;strong&gt;不要&lt;/strong&gt;把嘀嗒清单订阅添加到 iCloud 上面去（但是可以添加到 apple 设备的本地日历）&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;虽然我知道这不符合逻辑，但是我疑心觉得这是 iCloud 日历在解析订阅日程时出现的问题，所以只能这样做了。&lt;/p&gt;
&lt;h2&gt;所以&lt;/h2&gt;
&lt;p&gt;以上便是如此，最下面的方案便是我目前在使用的日程管理方案。目前看来还不错，能够满足我的需求。&lt;/p&gt;
&lt;p&gt;（不过冤种的我买了嘀嗒清单的会员，因为实际上貌似这个会员可有可无）&lt;/p&gt;
&lt;p&gt;当然，对于上面的最后一部分，如果你觉得太复杂，完全可以忽略这一部分，单纯使用日历来管理你的日程也是完全可以的。当然如果有更好的方案，也欢迎告诉我～&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.BtRElwsV.webp"/><category>life</category></item><item><title>Surge.sh 预览配置</title><link>https://blog.seeridia.top/blog/surge-preview</link><guid isPermaLink="true">https://blog.seeridia.top/blog/surge-preview</guid><description>为 Surge.sh 网站托管服务配置预览环境，以便在每次提交代码后自动生成预览链接，方便团队协作和反馈。</description><pubDate>Sun, 19 Oct 2025 01:18:00 GMT</pubDate><content:encoded>&lt;p&gt;Surge.sh 是一个静态网站托管服务，开发者可以快速将静态网站部署上线，方便进行预览和分享。通过配置 Surge.sh 预览环境，可以在每次代码提交后自动生成预览链接，比如可以放在 Pull Request 中，极大地方便了团队协作和反馈。&lt;/p&gt;
&lt;p&gt;本文来讲讲如何配置 Surge.sh 预览环境。&lt;/p&gt;
&lt;h2&gt;安装 Surge&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npm install -g surge

surge --version
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;创建 Surge 账号&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;surge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;命令执行后会提示：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Email：输入你的注册邮箱。&lt;/li&gt;
&lt;li&gt;Password：第一次会创建账号。&lt;/li&gt;
&lt;li&gt;Project path：默认就是当前目录。&lt;/li&gt;
&lt;li&gt;Domain：你可以使用 .surge.sh 的免费子域名，例如 my-project.surge.sh&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;获取 Surge Token&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;surge token
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;配置 GitHub Actions&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/pr-preview.yml

name: Deploy PR Preview to Surge.sh

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  issues: write
  pull-requests: write

jobs:
  deploy-preview:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Bun # 自行根据项目需要更改
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

      - name: Install dependencies
        run: bun install

      - name: Build project
        run: bun run build

      - name: Install Surge CLI
        run: bun install -g surge

      - name: Deploy to Surge.sh
        run: surge --project ./dist --domain Loomi-Lair-HomePage-pr-${{ github.event.number }}.surge.sh
        env:
          SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }}
          SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }}

      - name: Comment PR with preview URL
        uses: peter-evans/create-or-update-comment@v3
        with:
          issue-number: ${{ github.event.number }}
          body: |
            Preview deployed: https://Loomi-Lair-HomePage-pr-${{ github.event.number }}.surge.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;配置 GitHub Secrets&lt;/h2&gt;
&lt;p&gt;在你的 GitHub 仓库中，进入 &lt;code&gt;Settings&lt;/code&gt; -&gt; &lt;code&gt;Secrets and variables&lt;/code&gt; -&gt; &lt;code&gt;Actions&lt;/code&gt;，添加以下 Secrets：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SURGE_LOGIN&lt;/code&gt;：你的 Surge 注册邮箱。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SURGE_TOKEN&lt;/code&gt;：你通过刚刚的 &lt;code&gt;surge token&lt;/code&gt; 命令获取的 Surge Token。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;完成以上配置后，每当有 Pull Request 创建或更新时，GitHub Actions 会自动构建项目并部署到 Surge.sh，然后在 Pull Request 中添加预览链接，方便团队成员查看和反馈。&lt;/p&gt;
&lt;p&gt;Surge.sh 还有其他高级功能，比如自定义域名、SSL 证书等，可以根据需要进行配置（因为我还没有用到这些功能，懒了，不写了）&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.Dg_pvnS8.webp"/><category>website</category><category>ci/cd</category><category>github actions</category></item><item><title>打印色彩与文件交付</title><link>https://blog.seeridia.top/blog/print-color-file-delivery</link><guid isPermaLink="true">https://blog.seeridia.top/blog/print-color-file-delivery</guid><description>面向非专业设计的打印色彩与文件交付指南</description><pubDate>Sun, 14 Sep 2025 20:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本文主要面向非专业设计人员，比如只是在学校里面做做海报什么的，目标也只是普通的小文印店，而非专业的印刷厂。介绍打印色彩的基础知识以及文件交付的注意事项，帮助大家更好地理解打印色彩，避免或尽力减少在打印过程中出现色差等问题。&lt;/p&gt;
&lt;h2&gt;色彩基础&lt;/h2&gt;
&lt;p&gt;这部分不会太深入，主要介绍一些基础的色彩知识，帮助大家理解打印色彩。&lt;/p&gt;
&lt;h3&gt;sRGB&lt;/h3&gt;
&lt;p&gt;sRGB 是 1996 年由微软和惠普制定的一个标准色彩空间，最初是为了网络和普通显示器而设计的。几乎所有的手机、电脑、平板、网页浏览器默认使用 sRGB 作为显示标准。他是一种加色模型，主要通过红（Red）、绿（Green）、蓝（Blue）三种颜色的光线叠加来显示颜色。&lt;/p&gt;
&lt;h3&gt;CMYK&lt;/h3&gt;
&lt;p&gt;CMYK 是一种减色模型，主要用于印刷领域。CMYK 由下面四种颜色的油墨组合而成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;青（Cyan）&lt;/li&gt;
&lt;li&gt;品红（Magenta）&lt;/li&gt;
&lt;li&gt;黄（Yellow）&lt;/li&gt;
&lt;li&gt;黑（Key）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;通过叠加这四种颜色的油墨，可以生成各种颜色。与 RGB 不同，CMYK 是通过吸收光线来显示颜色的。因此在转换过程中可能会出现色差。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;加色模型 与 减色模型&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;加色模型&lt;/strong&gt;：通过叠加不同颜色的光来产生新的颜色。当三种颜色的光线以不同强度叠加时，可以产生各种颜色，最终叠加到白色光。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;减色模型&lt;/strong&gt;：通过吸收光线来产生新的颜色。在印刷过程中，油墨的叠加会吸收不同波长的光，从而生成各种颜色，最终叠加到黑色。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;sRGB&lt;/strong&gt; 是一种基于 &lt;strong&gt;RGB 模型&lt;/strong&gt;的标准色彩空间，广泛用于显示器、相机、网页等数字设备。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;屏幕上的每个像素由红、绿、蓝三个子像素组成。&lt;/li&gt;
&lt;li&gt;当这些子像素发出不同强度的光并混合时，人眼感知到不同的颜色。&lt;/li&gt;
&lt;li&gt;例如：
&lt;ul&gt;
&lt;li&gt;红光 + 绿光 = 黄光&lt;/li&gt;
&lt;li&gt;红光 + 蓝光 = 品红光&lt;/li&gt;
&lt;li&gt;绿光 + 蓝光 = 青光&lt;/li&gt;
&lt;li&gt;红 + 绿 + 蓝（全开）= 白光&lt;/li&gt;
&lt;li&gt;全关 = 黑色&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CMYK 用于印刷，依赖纸张反射环境光来显色。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;纸张本身是白色的，会反射所有可见光。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;油墨的作用是&lt;strong&gt;吸收（减去）某些颜色的光&lt;/strong&gt;，只反射我们看到的颜色。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;青色油墨吸收红光，反射绿光和蓝光（合成青色）&lt;/li&gt;
&lt;li&gt;品红油墨吸收绿光，反射红光和蓝光（合成品红）&lt;/li&gt;
&lt;li&gt;黄色油墨吸收蓝光，反射红光和绿光（合成黄色）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;当你叠印油墨时：&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;青 + 品红 = 吸收红和绿，只反射蓝 → 蓝色&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;青 + 黄 = 吸收红和蓝，只反射绿 → 绿色&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;品红 + 黄 = 吸收绿和蓝，只反射红 → 红色&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;青 + 品红 + 黄 = 理论上吸收所有光 → 黑色（但实际上呈深棕，所以加入 K——纯黑油墨）&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;色彩损失&lt;/h2&gt;
&lt;p&gt;所以，我们知道了在电脑屏幕上使用的是 RGB 色彩空间，而打印使用的是 CMYK 色彩空间。而 RGB 色彩空间的颜色范围要大于 CMYK 色彩空间的颜色范围。在将 RGB 颜色转换为 CMYK 颜色时，可能会出现色彩损失，导致打印效果与屏幕显示不一致。&lt;/p&gt;
&lt;p&gt;下图展示了在 CIE 1931 xy 色度图中，sRGB 和 CMYK 色域的对比。可以看到，CMYK 色域明显小于 sRGB 色域：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/CIE1931xy_gamut_comparison.CBe-XiWb_Ma0NS.svg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;图来源于 &lt;a href=&quot;https://en.wikipedia.org/wiki/CMYK_color_model#/media/File:CIE1931xy_gamut_comparison.svg&quot;&gt;维基百科&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;同时的，你也可以从图上很明显的看到，RGB → CMYK 的主要差别在鲜艳度和色域范围，典型情况是 &lt;strong&gt;鲜艳的蓝色、绿色、紫色、橙色&lt;/strong&gt;，在这些颜色上，CMYK 都会比 sRGB 差很多。&lt;/p&gt;
&lt;p&gt;下面展示一个个人的死亡案例&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/RGBvsCMYK.CMHhiMoN_Z7MJhh.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;（CMYK 校准后的图片 即 模拟打印设备的打印效果）&lt;/p&gt;
&lt;p&gt;笔者本人很大胆地使用了非常鲜艳的蓝色作为海报的主色调，直到设计完成后，才发现打印出来的效果和屏幕上差别巨大，蓝色变得非常暗淡，完全失去了原本的鲜艳感。&lt;/p&gt;
&lt;h2&gt;色彩空间选取&lt;/h2&gt;
&lt;p&gt;通过上面的描述，我们知道了 RGB 和 CMYK 的区别，以及在转换过程中可能会出现色彩损失。那么，在从设计到交付的全流程中，我们是否是要一直使用 CMYK 色彩空间呢？&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我认为的答案是在设计阶段 依然选择 sRGB&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;逻辑如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;从文印店的实际情况触发&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;普通的文印店不可能提供 ICC Profile 给你。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;ICC Profile&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它描述了“某种设备/油墨/纸张下，CMYK 数字值对应的真实颜色”。&lt;/p&gt;
&lt;p&gt;如果你用 ICC 转换 RGB → CMYK，就能更准确地模拟印刷效果。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文印店多数是数码打印机（激光/喷墨复印机），不是传统四色胶印。这些数码机内部往往有 多色墨盒（CMYK+额外的浅青、浅洋红，甚至橙/绿，例如拓展的 CMYKcm等等），驱动会自动做色彩转换和优化。&lt;/li&gt;
&lt;/ul&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;&lt;strong&gt;如果你交 sRGB&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;打印机驱动会根据 自身 ICC / 内部算法 做转换，尽量保留饱和度和细节。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果机器支持多色墨盒，可能比传统 CMYK 打出来的效果更好（比如蓝色会更亮，绿色会更正）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;相当于“把选择权交给打印机”，往往比你自己手动转 CMYK 更好，尤其是非专业的文印店。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;strong&gt;如果你交 CMYK&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;你交的数值就固定了，打印机不会再帮你优化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果你用的是普通 CMYK（比如 ISO Coated v2），就等于人为先把颜色砍掉一部分，可能比直接交 sRGB 更差。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;打印效果 → 蓝、绿、橙、紫更容易显得暗淡。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以在最开始的设计阶段，依然建议使用 sRGB 色彩空间进行设计。当然，在设计过程中，也有一些额外的注意事项。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但是，在我看来有下面这种情况更适用于使用 CMYK&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果你一开始就明确自己追求打印出来的颜色效果尽可能接近设计稿，并且自己确定不会使用过于鲜艳的蓝色、绿色、紫色、橙色等 CMYK 没有覆盖的颜色，那么你也可以选择在设计阶段就使用 CMYK 色彩空间进行设计。&lt;/p&gt;
&lt;p&gt;当然，如果是只用于在网络上展示的设计稿，那当然肯定是使用 sRGB 色彩空间。&lt;/p&gt;
&lt;h2&gt;设计阶段&lt;/h2&gt;
&lt;p&gt;如果使用 sRGB 色彩空间进行设计，建议注意以下几点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;尽量避免使用过于鲜艳的蓝色、绿色、紫色、橙色等颜色。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果必须使用，建议在设计软件中开启 CMYK 预览功能，查看这些颜色在 CMYK 下的表现。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/photoshop_cmyk_preview.CTSLkRPt_2uV1wG.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;如果使用 CMYK 色彩空间进行设计&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;避免使用超出 CMYK 色域的颜色&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/photoshop_out_of_gamut_warning.bSpdSyz5_Ytmz0.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;（Photoshop 会提示当前选取的颜色是否超出 CMYK 色域）&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.BsAMiBlB.webp"/><category>design</category></item><item><title>犀牛鸟 与 瞬间</title><link>https://blog.seeridia.top/blog/summer-of-code</link><guid isPermaLink="true">https://blog.seeridia.top/blog/summer-of-code</guid><description>记一次（首次）大厂开源活动</description><pubDate>Thu, 11 Sep 2025 01:30:00 GMT</pubDate><content:encoded>&lt;p&gt;一个偶然的机会，从西二那边的 Open-Source-Application 了解到了腾讯的犀牛鸟，便也想参与其中。&lt;/p&gt;
&lt;p&gt;倒不是只是知道这个活动，此前其实也有了解过 OSPP 以及 GSoC 之类的开源活动，只是太菜了，一眼看过去就觉得自己不行，没敢尝试。而犀牛鸟相比就友好得多了，甚至入门的介绍视频是教 Git 的使用的，这甚至是欢迎几乎零基础的同学吗？这倒让我去想着——或许我也行？&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.qq.com/aio/DTk1wUUFHUkZCQkZN?p=kBrlITmKIZYap9DtpMNaox&quot;&gt;https://docs.qq.com/aio/DTk1wUUFHUkZCQkZN?p=kBrlITmKIZYap9DtpMNaox&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;就从大前端的三个里面选了一个 Cherry，比较一眼看过去有点水水的？，毕竟看下去全都是样式的改进&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/1.BwCcMJGf_Z2lnJcu.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;这不就是改改 CSS 吗？所在我之前有接触 Typora 的主题的制作（刚好也是 markdown 编辑器），看着项目也颇为熟悉。&lt;/p&gt;
&lt;p&gt;于是很自然的选择了这个项目&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;从一封邮件开始说起&lt;/h2&gt;
&lt;p&gt;我看到这个项目的时候其实早在 6 月中旬我就看到了，而这个活动其实在 7 月 10 日才正式开始 issue 的占用和验收。&lt;/p&gt;
&lt;p&gt;或许确实是有自己的私心吧，因为这个项目看的就是谁抢的快，做的快，因为项目本身确实是没有什么技术含量的，我确实是是会担心可能会有人比我更快上手。所以我自己就想着早点开始准备，早点开始做。自己确实是是在那时候就已经准备好几个 issue 的相关代码了，就等着提交。不过，或许这个也不够，因为不仅仅是速度可能有人更快，质量也或许更高，数量更多。&lt;/p&gt;
&lt;p&gt;所以我自己就想着，我或许可以并不只局限于这几个 issue，而是可以尝试着去做更多的事情，更深度的参与这个项目，于是我自己在解决犀牛鸟 issue 的过程中，去看看这个项目有什么问题，有什么需要的地方。&lt;/p&gt;
&lt;p&gt;我最后是选取了 Cherry 的变量系统来讲的，发现当时的 Cherry 的变量系统并不完善，甚至可以说是非常的混乱。&lt;/p&gt;
&lt;p&gt;所以我给 Cherry 的导师 &lt;a href=&quot;https://github.com/sunsonliu&quot;&gt;@sunsonliu&lt;/a&gt; 发了一封邮件，详细阐述了我的想法和建议，部分邮件内容如下&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;邮件原文&lt;/code&gt; 我在 6 月 23 日 向导师发的邮件&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;项目理解&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我自己学习了一下 Cherry-markdown 的相关的样式，有发现了主要有如下问题：&lt;/p&gt;
&lt;p&gt;虽然我在 variable.css 中看到了基础的变量系统，但是其并不完整，以及大量实际使用中很多都仍然是硬编码&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;没有规定统一的圆角值，实际使用中部分组件有圆角，部分没有&lt;/li&gt;
&lt;li&gt;以 box-shadow 为代表的变量系统虽有定义，但大部分的实际使用也都是硬编码（每次使用都是一个新的阴影），甚至部分地方增加了很多不必要的多层阴影&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;改进方案&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;建立更加丰富的设计标记系统：定义完整的圆角、阴影、间距等的系统&lt;/li&gt;
&lt;li&gt;重构现有的硬编码样式，逐步替换当前的硬编码的样式&lt;/li&gt;
&lt;li&gt;建立组件基础库，例如建立个 cherry-card、cherry-button 这样的，统一规定其圆角、阴影，也方便后续开发减少工作量&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;我自己有上述的想法，并也有对应的能力去实现，所以我想请问是否有这样的计划，我能够独立去做出来，但是也有些顾虑——一来是这个工作量确实有点多，而且很多文件的修改会容易打乱同时期提交人的 PR；其次是这个项目据我所知有应用到有商业化的腾讯文档等地方，我不清楚是否会收到影响&lt;/p&gt;
&lt;p&gt;上述意见也终归来自于一个只有些许经验的学生，如有意见，也恳求批评指正。我自己看到这个项目，就有想要参与成为 contributor 成员的冲动，即便或许不是通过犀牛鸟项目去着手这个项目。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;虽然我不方便在这边展示后续的导师回信内容，乃至之后的交流内容，我想说的是 &lt;strong&gt;沟通是非常重要的&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不论是提早和导师沟通，还是在遇到问题时及时寻求帮助，都是非常必要的。通过他，我也得到了很多的帮助和指导，甚至在后续的 PR 评审中也得到了很多的建议。甚至专门开了我上面提到的对应的变量系统的改进的实战任务 Issue&lt;/p&gt;
&lt;h2&gt;Collaborator!&lt;/h2&gt;
&lt;p&gt;从上面这个变量系统重构的 PR 开始，作为一个贡献者，就直接一发不可收拾了，截止整个犀牛鸟的活动结束，我一共参与了 27 个 PR，如果论 Commit 的数量，应该是有将近 200 个了（Squash 前）&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/2.DrjGLT2w_ZC8Wle.webp&quot; alt=&quot;犀牛鸟项目结题报告的 PPT&quot;&gt;&lt;/p&gt;
&lt;p&gt;（这个是直接拿我最后项目报告里面的 PPT）&lt;/p&gt;
&lt;p&gt;甚至比较惊喜的发现在 Openomy 的数据评估下，我的分数已经达到了该仓库的第四。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.openomy.com/svg?repo=Tencent/cherry-markdown&amp;#x26;chart=list&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;（这个图片我特意换成会更新的，看看我的排名有没有变化？）&lt;/p&gt;
&lt;p&gt;当然，上面的这些都不及一封邮件来得更令我欢喜了几天&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/3.BPr1sSVF_247GaW.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;这是导师给我发的 Collaborate 邀请&lt;/p&gt;
&lt;p&gt;也从那时候开始，我也正式成为开发组的一员了，是对我过去付出的最大的认可，远超我以往所经历的任何瞬间，在那时，开源的意义也变得更加深刻了——一位位前辈带着我去走向更高的山，或许不知什么时候，我也能成为带领他人的那一位。&lt;/p&gt;
&lt;h2&gt;尾声&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;更新于 11 月 19 日&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;结果也如我所愿，拿到了犀牛鸟全部的奖项，在我可预见的未来中，或许我会一直把这段经历放在简历的最前面，虽然具体的工作其实是蛮简单的，但是也或许是最亮眼最深刻的经历了&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/Winner-List.BHFp9WGo_lJAFc.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;昨天也收到了腾讯那边发来的包裹（终于到了）&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/Awards-Certificate.Cf4c0VOl_aSalf.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;不过奖金怎么还没发，Qwq（因为我至今都不清楚我会拿到多少钱），看着这个 11 月能有吧？&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.D8PjyRxB.webp"/><category>开源活动</category><category>杂谈</category></item><item><title>上传照片（服务端）</title><link>https://blog.seeridia.top/blog/image-upload</link><guid isPermaLink="true">https://blog.seeridia.top/blog/image-upload</guid><description>如何处理照片上传、存储和处理</description><pubDate>Tue, 02 Sep 2025 08:05:00 GMT</pubDate><content:encoded>&lt;p&gt;同样源自于大一的大作业 Shotmeld 项目&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/image-upload&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;先把最终流程摆出来吧&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    autonumber
    title Shotmeld 照片上传与智能处理流程

    participant Client as 客户端&amp;#x3C;br&gt;(Web / App)
    participant Server as 服务端&amp;#x3C;br&gt;(主控协调)
    participant OSSUploader as OSS同步模块&amp;#x3C;br&gt;(异步上传)
    participant EXIFProcessor as EXIF处理模块&amp;#x3C;br&gt;(坐标+逆地理)
    participant AITagger as AI标签模块&amp;#x3C;br&gt;(图像识别)
    participant AliyunOSS as 阿里云OSS&amp;#x3C;br&gt;(原图存储)
    participant GaodeAPI as 高德API&amp;#x3C;br&gt;(逆地理编码)
    participant ImageAI as 阿里云图像识别API&amp;#x3C;br&gt;(智能打标)

    Client -&gt;&gt; Server: 1. 上传图片 (multipart/form-data)
    activate Server
    Server -&gt;&gt; Server: 2. 关联相册 + 生成缩略图&amp;#x3C;br&gt;快速响应准备
    Server --&gt;&gt; Client: 3. 返回“上传成功”
    deactivate Server

    Note over Client,Server: 第3步返回的图片URL指向&amp;#x3C;strong&gt;服务端本地存储&amp;#x3C;/strong&gt;，&amp;#x3C;br&gt;由服务端直接提供文件访问，确保用户“秒级预览”

    par OSS 上传流程
        Server -&gt;&gt; OSSUploader: 4. 异步：上传原图至OSS
        activate OSSUploader
        OSSUploader -&gt;&gt; AliyunOSS: 5. 上传原始图片
        AliyunOSS --&gt;&gt; OSSUploader: 6. 返回OSS URL
        OSSUploader -&gt;&gt; Server: 7. 更新数据库：图片URL切换至OSS地址
        deactivate OSSUploader
    and EXIF 解析流程
        Server -&gt;&gt; EXIFProcessor: 8. 异步：解析EXIF元数据
        activate EXIFProcessor
        EXIFProcessor -&gt;&gt; EXIFProcessor: 9. 火星坐标 → WGS84&amp;#x3C;br&gt;坐标纠偏
        EXIFProcessor -&gt;&gt; GaodeAPI: 10. 请求逆地理编码&amp;#x3C;br&gt;经纬度 → 省市区街道
        GaodeAPI --&gt;&gt; EXIFProcessor: 11. 返回结构化地址
        EXIFProcessor -&gt;&gt; Server: 12. 更新地理标签与拍摄地点
        deactivate EXIFProcessor
    and AI 标签识别流程
        Server -&gt;&gt; AITagger: 13. 异步：图像内容识别
        activate AITagger
        AITagger -&gt;&gt; ImageAI: 14. 提交图片URL&amp;#x3C;br&gt;物体/场景/OCR识别
        ImageAI --&gt;&gt; AITagger: 15. 返回标签列表&amp;#x3C;br&gt;如：“猫, 窗户, 阳光”
        AITagger -&gt;&gt; Server: 16. 保存AI标签至数据库
        deactivate AITagger
    end

    Note over Server,AliyunOSS: 第7步完成后，&amp;#x3C;strong&gt;所有图片访问切换至阿里云OSS&amp;#x3C;/strong&gt;，&amp;#x3C;br&gt;服务端释放存储与带宽压力，实现“前端快响应，后端稳迁移”

    Note right of Server: 最终成果：&amp;#x3C;br&gt;• 图片通过OSS访问&amp;#x3C;br&gt;• 带地理位置&amp;#x3C;br&gt;• 带AI智能标签&amp;#x3C;br&gt;• 支持搜索与推荐
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下面开始讲讲思考&lt;/p&gt;
&lt;h2&gt;上传流程设计思路&lt;/h2&gt;
&lt;h3&gt;初版云端上传流程与初步优化&lt;/h3&gt;
&lt;p&gt;最初的上传流程非常简单：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端直接上传图片至服务器。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;随着功能迭代，我们意识到首页展示多张图片时，不应直接使用原图，因此增加了缩略图步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端上传图片至服务器。&lt;/li&gt;
&lt;li&gt;服务器创建一张体积小于 100KB 的缩略图。&lt;/li&gt;
&lt;li&gt;首页加载缩略图，点击后加载原图。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;然而，这并未解决原图访问速度慢的根本问题。更重要的是，后期我们集成了更多图片处理功能，使得流程愈发复杂：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端上传图片至服务器。&lt;/li&gt;
&lt;li&gt;处理图片与相册的关联。&lt;/li&gt;
&lt;li&gt;创建缩略图。&lt;/li&gt;
&lt;li&gt;解析图片 EXIF 信息（拍摄时间、光圈、快门速度等）。&lt;/li&gt;
&lt;li&gt;对 EXIF 中的经纬度进行火星坐标系转换。&lt;/li&gt;
&lt;li&gt;将转换后的经纬度提交至高德 API 进行逆地理编码。&lt;/li&gt;
&lt;li&gt;将图片提交至阿里云图像识别 API 以获取标签。&lt;/li&gt;
&lt;li&gt;将识别出的标签存入图片关联数据库。&lt;/li&gt;
&lt;li&gt;向客户端返回上传成功的响应。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在这一系列同步操作下，处理单张图片的时间飙升至致命的 15 秒。相比之下，这些操作在本地环境中几乎是瞬时完成的。这揭示了一个在纯本地开发时难以预见的性能瓶颈，也促使我们着手解决。&lt;/p&gt;
&lt;h3&gt;引入阿里云 OSS：提升访问速度&lt;/h3&gt;
&lt;p&gt;为解决服务器带宽和存储的瓶颈，我们引入了 阿里云对象存储（OSS）服务。OSS 的以下特性对我们的项目尤为关键：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;海量存储 （Massive Storage）：OSS 采用“桶（Bucket）”和“对象（Object）”的扁平结构，理论上容量无限，可按需自动扩展，无需预估硬盘空间。&lt;/li&gt;
&lt;li&gt;高可靠性 （High Reliability）：阿里云承诺极高的数据持久性（12 个 9），通过多副本冗余机制将数据分散存储于不同物理设备和可用区，有效防止单点故障。同时支持版本控制，防止意外删除或覆盖。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最重要的是，OSS 能够显著提升用户访问图片的速度。经过一番文档研读和在 AI 辅助下的集成，我们将所有文件存储逻辑迁移到了 OSS。&lt;/p&gt;
&lt;p&gt;迁移后效果显著：常规 5MB 大小的图片，访问时间能控制在 0.5 秒以内。&lt;/p&gt;
&lt;h3&gt;OSS 迁移后的新挑战：上传速度恶化&lt;/h3&gt;
&lt;p&gt;尽管访问速度大幅提升，但上传流程变为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端上传图片至服务器。&lt;/li&gt;
&lt;li&gt;服务器将图片上传至 OSS。&lt;/li&gt;
&lt;li&gt;处理图片与相册的关联。&lt;/li&gt;
&lt;li&gt;创建缩略图。&lt;/li&gt;
&lt;li&gt;解析图片 EXIF 信息。&lt;/li&gt;
&lt;li&gt;经纬度火星坐标转换与逆地理编码。&lt;/li&gt;
&lt;li&gt;阿里云图像识别 API 获取标签。&lt;/li&gt;
&lt;li&gt;保存标签至数据库。&lt;/li&gt;
&lt;li&gt;向客户端返回上传成功的响应。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不难想到的是，尽管用户访问图片能获得更好的体验，由于增加了服务器到 OSS 的中转环节，图片上传时间反而延长至 20 秒，情况变得更糟。&lt;/p&gt;
&lt;h3&gt;最终解决方案：异步处理优化&lt;/h3&gt;
&lt;p&gt;为彻底解决上传耗时问题，我们设计并实施了最终的异步处理工作流：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;即时响应阶段（同步处理）：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端上传文件至服务器&lt;/li&gt;
&lt;li&gt;服务器处理相册关联等即时性操作&lt;/li&gt;
&lt;li&gt;服务器快速生成缩略图&lt;/li&gt;
&lt;li&gt;服务器立即向客户端返回上传成功的响应（此时图片暂时由服务器承载，优先保障用户能第一时间查看和操作）。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;后台异步处理阶段：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;OSS 同步模块：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;将原图和缩略图异步上传至 OSS&lt;/li&gt;
&lt;li&gt;待 OSS 上传完成后，更新数据库中图片的 URL 指向 OSS&lt;/li&gt;
&lt;li&gt;EXIF 处理模块：
&lt;ol&gt;
&lt;li&gt;异步解析图片 EXIF 信息&lt;/li&gt;
&lt;li&gt;进行经纬度火星坐标转换&lt;/li&gt;
&lt;li&gt;调用高德 API 进行逆地理编码&lt;/li&gt;
&lt;li&gt;将处理后的 EXIF 数据更新至数据库&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;AI 标签模块：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;异步调用阿里云图像识别 API 获取图片标签&lt;/li&gt;
&lt;li&gt;将获取的标签保存至数据库&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;通过上述优化，对于一张约 2MB 的普通图片，我们成功将上传时间控制在 2 秒以内，同时保持了查看速度在 0.3 秒以内的优异表现。&lt;/p&gt;
&lt;h2&gt;Appendix&lt;/h2&gt;
&lt;h3&gt;地图坐标转换&lt;/h3&gt;
&lt;p&gt;我们在 Exif 解析的时候，想要根据 Exif 中的信息来得到一个结构化的地址（例如 福建省福州市福州大学（旗山校区） 这样的具体名称），而非仅仅是经纬度坐标。为此，我们需要引入高德 API 进行逆地理编码。&lt;/p&gt;
&lt;p&gt;但是在这之前，需要知道的是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;中国大陆境内采用的都是 GCJ-02 坐标系（火星坐标系），而非 WGS-84 坐标系（GPS 坐标系）&lt;/li&gt;
&lt;li&gt;图片 Exif 中存储的经纬度信息是 WGS-84 坐标系&lt;/li&gt;
&lt;li&gt;高德 API 的逆地理编码只支持 GCJ-02 坐标系，因此我们需要将 WGS-84 坐标系转换为 GCJ-02 坐标系&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我自己是直接使用 高德 API 提供的坐标转换接口来完成的，当然也可以直接本地转换&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://lbs.amap.com/api/webservice/guide/api/convert&quot;&gt;https://lbs.amap.com/api/webservice/guide/api/convert&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/image-upload&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;/**
 * 将GPS（WGS84）坐标转换为高德（GCJ-02）坐标系并获取位置名称
 * @param {number} longitude - GPS经度
 * @param {number} latitude - GPS纬度
 * @returns {Promise&amp;#x3C;{longitude: number, latitude: number, name: string|null}&gt;} - 包含高德坐标和位置名称的对象
 */
async function convertGPSToAMap(originalLongitude, originalLatitude) {
  try {
    // 验证输入坐标的有效性
    if (typeof originalLongitude !== &apos;number&apos; || typeof originalLatitude !== &apos;number&apos; ||
        isNaN(originalLongitude) || isNaN(originalLatitude)) {
      throw new Error(&apos;无效的坐标值&apos;);
    }

    // 确保有API Key
    if (!config.AMAP_KEY) {
      console.warn(&apos;未配置高德地图API Key，无法进行坐标转换和逆地理编码&apos;);
      return { longitude: originalLongitude, latitude: originalLatitude, name: null };
    }

    let amapLongitude = originalLongitude;
    let amapLatitude = originalLatitude;
    let locationName = null;

    // 1. 坐标转换 (WGS84 to GCJ-02)
    const convertUrl = `https://restapi.amap.com/v3/assistant/coordinate/convert?key=${config.AMAP_KEY}&amp;#x26;locations=${originalLongitude},${originalLatitude}&amp;#x26;coordsys=gps`;
    try {
      const convertResponse = await axios.get(convertUrl);
      if (convertResponse.data &amp;#x26;&amp;#x26; convertResponse.data.status === &apos;1&apos; &amp;#x26;&amp;#x26; convertResponse.data.locations) {
        const [lon, lat] = convertResponse.data.locations.split(&apos;,&apos;);
        amapLongitude = parseFloat(lon);
        amapLatitude = parseFloat(lat);
      } else {
        console.error(&apos;高德地图坐标转换API返回错误:&apos;, convertResponse.data);
      }
    } catch (convertError) {
      console.error(&apos;高德地图坐标转换失败:&apos;, convertError);
    }
    
    // 2. 逆地理编码获取位置名称 (使用转换后的或原始的高德坐标)
    const locationStringForRegeo = `${amapLongitude},${amapLatitude}`;
    const regeoUrl = `https://restapi.amap.com/v3/geocode/regeo?key=${config.AMAP_KEY}&amp;#x26;location=${locationStringForRegeo}&amp;#x26;extensions=base`;
    
    try {
        const regeoResponse = await axios.get(regeoUrl);
        if (regeoResponse.data &amp;#x26;&amp;#x26; regeoResponse.data.status === &apos;1&apos; &amp;#x26;&amp;#x26; regeoResponse.data.regeocode) {
          locationName = regeoResponse.data.regeocode.formatted_address;
        } else {
          console.error(&apos;高德地图逆地理编码API返回错误:&apos;, regeoResponse.data);
        }
    } catch (regeoError) {
        console.error(&apos;高德地图逆地理编码失败:&apos;, regeoError);
    }

    // 返回转换后的高德经纬度和获取到的位置名称
    return {
      longitude: amapLongitude,
      latitude: amapLatitude,
      name: locationName
    };

  } catch (error) {
    console.error(&apos;获取位置名称或坐标转换主流程失败:&apos;, error);
    return { longitude: originalLongitude, latitude: originalLatitude, name: null }; 
  }
}
&lt;/code&gt;&lt;/pre&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.B8q_T-9P.webp"/><category>图片上传</category><category>backend</category></item><item><title>极低成本的“自然语言图像检索”实现</title><link>https://blog.seeridia.top/blog/image-search</link><guid isPermaLink="true">https://blog.seeridia.top/blog/image-search</guid><description>通过 LLM 处理图像检索</description><pubDate>Sun, 24 Aug 2025 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;该实现方式是我在完成校内大作业的时候想到的，并已经在实际项目中得到了应用。通过将自然语言处理与图像检索相结合，我们能够以极低的成本实现高效的图像搜索功能。&lt;/p&gt;
&lt;p&gt;项目 Github 链接：&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/image-search&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;目前常规的“文搜图”基础普遍依赖“向量搜索（Vector Search）”的方式实现，但是势必依赖较好的服务端资源，可惜我们这边当时并没有那么好的条件 QwQ，所以只能使用非常 投机取巧 灵活的方式去实现这个功能。&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;常规实现——向量搜索&lt;/h2&gt;
&lt;p&gt;当然，我们用的不是这个方法，我只是这边贴一下，方便顺便学习。不过这个也是最主流，效果更好的方法，如果不差钱，这个是最推荐的。不过我的方法也是在这上面延伸出来的。&lt;/p&gt;
&lt;p&gt;就算是正常的“向量搜索（Vector Search）”也会把“文搜图”工作分成如下几步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;理解图片 (Image Understanding)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;当将照片存入相册或图库时，AI模型（如深度学习中的卷积神经网络CNN或Vision Transformer）会自动分析每一张照片。&lt;/p&gt;
&lt;p&gt;它会识别出照片中的各种元素，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;物体:&lt;/strong&gt; 猫、狗、汽车、桌子、电脑&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;场景:&lt;/strong&gt; 海滩、城市街道、森林、室内房间&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;动作:&lt;/strong&gt; 跑步、微笑、吃饭、弹吉他&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;属性:&lt;/strong&gt; 蓝色、圆形、大的、模糊的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后，AI会将这些复杂的视觉信息转换成一个数学表示，称为 &lt;strong&gt;“图像嵌入” (Image Embedding)&lt;/strong&gt; 或“特征向量”。这个向量可以被看作是这张照片在AI“大脑”中的一个独一无二的“语义坐标”。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;理解文字 (Text Understanding):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当在搜索框中输入文字，比如“在海边奔跑的金色小狗”时，另一个AI模型（如BERT或CLIP的文本部分）会来处理这段文字。&lt;/li&gt;
&lt;li&gt;它会分析这段话的语法和语义，理解其中的关键概念（海边、奔跑、金色、小狗）。&lt;/li&gt;
&lt;li&gt;同样地，AI也会将这段文字转换成一个数学表示，称为 &lt;strong&gt;“文本嵌入” (Text Embedding)&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;匹配和搜索 (Matching and Searching):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;现在，AI有了代表您搜索词的“文本向量”和代表每一张照片的“图像向量”。&lt;/li&gt;
&lt;li&gt;最关键的一步是，这些向量存在于同一个“多模态语义空间”中。在这个空间里，意思相近的文本和图片，它们的向量坐标也相近。&lt;/li&gt;
&lt;li&gt;因此，系统只需要计算您的文本向量与图库中所有图像向量的“距离”，然后将距离最近、最匹配的那些照片作为搜索结果返回给您。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;上面的就是向量搜索的方法，也是业内主流采用的方式&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;奇技淫巧&lt;/h2&gt;
&lt;p&gt;正题开始！&lt;/p&gt;
&lt;p&gt;仿照上面的思路，我将“文搜图”的工作分成了两步：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;图片转描述词&lt;/li&gt;
&lt;li&gt;搜索词匹配描述词&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;图片转描述词这步其实蛮简单的，只要把它理解成“图片打标”，就有许多现成的 API 可以用，比如我当时用的阿里家的&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://help.aliyun.com/zh/viapi/developer-reference/api-t057e7&quot;&gt;https://help.aliyun.com/zh/viapi/developer-reference/api-t057e7&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;接下来一步就很神奇，我们从 api 那边拿到了每个图片的标签，我们该如何用这些标签去匹配用户的搜索词呢？当然，正常的方法是词向量匹配，但是由于相同的原因，所以我就想到了用 LLM 来做这个匹配。&lt;/p&gt;
&lt;p&gt;（当时没有使用词向量的原因和上面差不多，但是当时傻，不知道有文本向量特化模型，下面我会说到）&lt;/p&gt;
&lt;p&gt;流程如下&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TB
    subgraph A [&quot;照片索引阶段 (一次性处理) &quot;]
        direction LR
        Photos([个人照片库]) --&gt; API{{调用图像打标 API, 如: 阿里云}}
        API --&gt; Tags[返回图片标签, 例如: &apos;河流&apos;, &apos;小船&apos;]
        Tags --&gt; DB[(标签数据库)]
        Photos -- 路径/ID --&gt; DB
    end

    A --&gt; B

    subgraph B [&quot;用户搜索阶段 (实时查询) &quot;]
        direction TB
        UserInput([用户输入自然语言: &apos;在河边的日落&apos;]) --&gt; Prompt
        DB -- 提取所有可用标签 --&gt; Prompt
        Prompt{{组合成 Prompt}} --&gt; LLM[请求 LLM 匹配]
        LLM --&gt; MatchedTags[LLM 返回最匹配的标签,例如: &apos;河流&apos;, &apos;日落&apos;]
        MatchedTags --&gt; SearchDB{{查询标签数据库}}
        DB -- 被查询 --&gt; SearchDB
        SearchDB --&gt; Results[找到所有匹配的图片]
        Results --&gt; Display([向用户展示结果])
    end

    %% ====== 样式美化 ======
    style A fill:#fff8dc,stroke:#333,stroke-width:1px,rx:10,ry:10
    style B fill:#e6f2ff,stroke:#333,stroke-width:1px,rx:10,ry:10
    style DB fill:#f9c,stroke:#333,stroke-width:2px
    style LLM fill:#bbf,stroke:#333,stroke-width:2px
    style UserInput fill:#eee,stroke:#666,rx:8,ry:8
    style Display fill:#eee,stroke:#666,rx:8,ry:8

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;流程很好解决，接下来就是一些细节的处理&lt;/p&gt;
&lt;h3&gt;信息设计&lt;/h3&gt;
&lt;h4&gt;输入信息&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;标签库&lt;/strong&gt;：JSON 格式的标签列表，例如&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;tags&quot;: [&quot;旅行&quot;, &quot;时尚&quot;, &quot;化妆&quot;, &quot;风景&quot;, &quot;美食&quot;]}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;搜索查询&lt;/strong&gt;：用户输入的搜索内容，例如：“旅行”。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;输出要求&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;以 JSON 格式返回匹配标签列表，格式为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;matched_tags&quot;: [&quot;标签1&quot;, &quot;标签2&quot;, ...]}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;输出必须为合法 JSON，仅包含 &lt;code&gt;matched_tags&lt;/code&gt; 数组。&lt;/li&gt;
&lt;li&gt;不能包含解释、注释或额外文本。&lt;/li&gt;
&lt;li&gt;若无匹配标签，则返回 &lt;code&gt;{&quot;matched_tags&quot;: []}&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;提示词设计&lt;/h3&gt;
&lt;p&gt;这边就需要我们去用心设计一个提示词，因为我们需要保证 LLM 稳定地返回我们所需的内容。当然也有一个大家熟知的技巧，用 LLM 来帮我们写提示词。&lt;/p&gt;
&lt;p&gt;除了规定其输出规则外，我这边也给了其核心匹配原则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;相关性联想：匹配与搜索词在功能、场景或属性上紧密相关的事物。例如，搜索“学习”时，可以关联到“书籍”、“课程”、“考试”等标签。&lt;/li&gt;
&lt;li&gt;场景化匹配：关键在于理解搜索词背后的场景和需求。举个关键例子：如果用户搜索“湖水”，你不仅要匹配“湖”，更需要智能地联想到相关的“河”、“江”、“溪”、“水”等，因为它们都属于“水体”这一核心概念。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;确保 AI 能给我更好的匹配效果&lt;/p&gt;
&lt;p&gt;提示词&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;角色与目标 (Role &amp;#x26; Goal):
你是一位专精于概念关联与语义理解的智能标签匹配引擎。你的核心任务是深入分析用户的搜索意图，并从一个给定的标签库中，广泛地匹配出所有相关的标签。

核心匹配原则 (Core Matching Principles):
你的匹配不应局限于字面上的同义词，而必须扩展到更广泛的语义关联维度：

相关性联想 (Associative Relevance): 匹配与搜索词在功能、场景或属性上紧密相关的事物。例如，搜索“学习”时，可以关联到“书籍”、“课程”、“考试”等标签。
场景化匹配 (Contextual Matching): 关键在于理解搜索词背后的场景和需求。举个关键例子：如果用户搜索“湖水”，你不仅要匹配“湖”，更需要智能地联想到相关的“河”、“江”、“溪”、“水”等，因为它们都属于“水体”这一核心概念。

输入信息 (Input Information):

所有标签的 JSON 文件如下：
${JSON.stringify({ tags: allTags }, null, 2)}

用户的搜索内容是：
${searchQuery}

输出要求 (Output Requirements):

请根据上述原则，匹配出与搜索内容相关的所有标签，并仅以 JSON 格式返回匹配的标签列表。

返回格式示例：
{ &quot;matched_tags&quot;: [&quot;tag1&quot;, &quot;tag2&quot;] }

严格遵守以下规则 (Strict Rules):

只返回 JSON：绝对不要添加任何解释、注释或额外的文字。
结果必须是有效的 JSON 数组。
若无相关标签：返回一个包含空数组的 JSON 对象，即 {&quot;matched_tags&quot;: []}。
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;API 请求示例&lt;/h3&gt;
&lt;p&gt;请求示例代码&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import OpenAI from &quot;openai&quot;;

const openai = new OpenAI({
apiKey: process.env.API_KEY,
baseURL: &quot;https://dashscope.aliyuncs.com/compatible-mode/v1&quot;,
});

async function main() {
const tags = [&quot;旅行&quot;, &quot;时尚&quot;, &quot;化妆&quot;, &quot;风景&quot;, &quot;美食&quot;];
const searchQuery = &quot;旅行&quot;;
const prompt = `
 角色与目标 (Role &amp;#x26; Goal):
  你是一位专精于概念关联与语义理解的智能标签匹配引擎。你的核心任务是深入分析用户的搜索意图，并从一个给定的标签库中，广泛地匹配出所有相关的标签。

 核心匹配原则 (Core Matching Principles):
  你的匹配不应局限于字面上的同义词，而必须扩展到更广泛的语义关联维度：
      相关性联想 (Associative Relevance): 匹配与搜索词在功能、场景或属性上紧密相关的事物。例如，搜索“学习”时，可以关联到“书籍”、“课程”、“考试”等标签。
      场景化匹配 (Contextual Matching): 关键在于理解搜索词背后的场景和需求。举个关键例子：如果用户搜索“湖水”，你不仅要匹配“湖”，更需要智能地联想到相关的“河”、“江”、“溪”、“水”等，因为它们都属于“水体”这一核心概念。
 输入信息 (Input Information):
     所有标签的 JSON 文件如下：
      ${JSON.stringify({ tags: allTags }, null, 2)}

      用户的搜索内容是：
      ${searchQuery}

 输出要求 (Output Requirements):
  请根据上述原则，匹配出与搜索内容相关的所有标签，并仅以 JSON 格式返回匹配的标签列表。

 返回格式示例：
  { &quot;matched_tags&quot;: [&quot;tag1&quot;, &quot;tag2&quot;] }

 严格遵守以下规则 (Strict Rules):
  只返回 JSON：绝对不要添加任何解释、注释或额外的文字。
  结果必须是有效的 JSON 数组。
  若无相关标签：返回一个包含空数组的 JSON 对象，即 {&quot;matched_tags&quot;: []}。
   `;

const completion = await openai.chat.completions.create({
  model: &quot;qwen-turbo&quot;,
  messages: [
    { role: &quot;system&quot;, content: &quot;你是一个智能助手。&quot; },
    { role: &quot;user&quot;, content: prompt },
  ],
});

console.log(completion.choices[0].message.content);
}

main();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这边我采用的是 &lt;code&gt;qwen-turbo&lt;/code&gt; 模型，主要看中其速度，十次平均 420ms。&lt;/p&gt;
&lt;p&gt;我虽然当时采用的是 &lt;code&gt;qwen-turbo&lt;/code&gt; ，而实际上我们完全可以使用“阿里通用文本向量-v4(text-embedding-v4)”这类特化模型，他们不是思考，而是纯粹的数学计算，延迟更低，效果更好，可惜当时傻，我有时间会去重新做这一块。&lt;/p&gt;
&lt;p&gt;不过这样上面的 API 请求什么都不一样，可以参考&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://bailian.console.aliyun.com/?spm=a2c4g.11186623.0.0.3adc55efAtoAJ4&amp;#x26;tab=doc#/doc/?type=model&amp;#x26;url=2842587&quot;&gt;https://bailian.console.aliyun.com/?spm=a2c4g.11186623.0.0.3adc55efAtoAJ4&amp;#x26;tab=doc#/doc/?type=model&amp;#x26;url=2842587&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;评估&lt;/h2&gt;
&lt;h3&gt;优点&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;成本极低，无论是图像打标还是 LLM 对话&lt;/li&gt;
&lt;li&gt;实现简单&lt;/li&gt;
&lt;li&gt;效果好：利用大语言模型做“语义理解”的中间层，比传统的关键词匹配要智能得多&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;缺点&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;搜索的粒度和深度有限&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;关系丢失: 图像打标API通常返回的是离散的、名词性的标签，它们之间没有关系。比如一张“一个男人在追逐一只狗”的照片，标签可能是&lt;code&gt;[&quot;男人&quot;, &quot;狗&quot;, &quot;奔跑&quot;, &quot;草地&quot;]&lt;/code&gt;。无法通过这些标签搜索“谁在追谁”这种带有关系和动作主体的复杂场景。系统可以找到同时包含这些标签的照片，但无法理解它们之间的逻辑。&lt;/li&gt;
&lt;li&gt;属性和细节丢失: 对于“穿红色裙子的女孩”这样的搜索，如果打标API只返回了&lt;code&gt;[&quot;女孩&quot;, &quot;裙子&quot;]&lt;/code&gt;而没有“红色”这个属性，搜索就会失败。标签的质量完全决定了搜索结果的上限。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;结果排序困难&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个方案本质上是“布尔搜索”（要么匹配，要么不匹配）。如果多个结果都匹配了AI返回的标签（例如，10张照片都有“河流”和“日落”的标签），很难判断哪一张是&lt;strong&gt;最&lt;/strong&gt;匹配用户描述的。而真正的向量搜索可以根据向量距离进行排序，给出最相关的结果。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;后日谈&lt;/h2&gt;
&lt;p&gt;上面的内容产出都是我在当时做那个项目的时候思考的，也实际使用了，你也可以去试试看效果&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://shotmeld.seeridia.top/&quot;&gt;https://shotmeld.seeridia.top&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;已经停止运营&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;当然我上面也提到了，我当时并不知道阿里云那边也有专门的文本向量模型，我知道词向量，但是由于服务器拉了没法本地跑就没用了。&lt;/p&gt;
&lt;p&gt;而结果是这个模型更便宜，效果更好，延迟更低 QwQ，确实是有点小伤心的，不过在这边也学到蛮多的，没办法，有时间去换一换吧，之后再更新这篇文章&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.fNPAEoPW.webp"/><category>web</category><category>llm</category><category>trick</category></item><item><title>从网站的上线开始讲起（二）</title><link>https://blog.seeridia.top/blog/website-launched-2</link><guid isPermaLink="true">https://blog.seeridia.top/blog/website-launched-2</guid><description>域名 与 DNS</description><pubDate>Fri, 22 Aug 2025 22:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我就从 域名 → DNS → CDN → ESA 这条链路开始看看一个网站的上线，特别是在国内的网络环境下，如何保证用户能够快速、稳定地访问。不过本篇只会有域名和DNS，后续会补上CDN（这部分太多了）。&lt;/p&gt;
&lt;p&gt;本文的部分内容和供图来自阿里云的 &lt;a href=&quot;https://help.aliyun.com/zh/dns/basic-concepts-dns2-0&quot;&gt;帮助文档&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;域名（Domain Name）&lt;/h2&gt;
&lt;h3&gt;概念&lt;/h3&gt;
&lt;p&gt;域名就是互联网的“门牌号”，让人们可以用 文字 来访问网站，而不用记一长串 IP 地址。&lt;/p&gt;
&lt;p&gt;举例：&lt;code&gt;seeridia.top&lt;/code&gt; 其实只是一个名字，它背后最终会对应到服务器的 IP（例如 &lt;code&gt;172.31.49.252&lt;/code&gt; ）。&lt;/p&gt;
&lt;p&gt;域名属于 ICANN 统一管理，注册商（阿里云、腾讯云、Namecheap 等）相当于“代理商”。&lt;/p&gt;
&lt;h3&gt;关键知识点&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;域名层级&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;顶级域名（TLD）： &lt;code&gt;.com&lt;/code&gt;, &lt;code&gt;.cn&lt;/code&gt;, &lt;code&gt;.top&lt;/code&gt; 等&lt;/li&gt;
&lt;li&gt;二级域名：&lt;code&gt;seeridia.top&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;子域名：&lt;code&gt;api.seeridia.top&lt;/code&gt;, &lt;code&gt;cdn.seeridia.top&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;注意：每一级域名都可以单独做解析配置。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;域名注册与所有权&lt;/p&gt;
&lt;p&gt;注册域名后，获得一定年限的使用权。必须定期续费，否则可能被别人抢注。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;域名系统依赖 DNS&lt;/p&gt;
&lt;p&gt;域名本身只是名字，必须依赖 DNS（域名系统）才能解析成 IP 地址，否则浏览器没法访问。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/4056354571/CAEQYBiBgMC3qveJxBkiIDY4MGZjNjdiZDM1ZTQzYjliZWM1ZTFjMmIyNTNiNzY23963382_20230830144006.372.svg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h2&gt;DNS（Domain Name System）&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;作用&lt;/strong&gt;：把人类可读的域名（&lt;code&gt;seeridia.top&lt;/code&gt;）转换成计算机可用的 IP 地址（&lt;code&gt;172.31.49.252&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;类比&lt;/strong&gt;：就像电话簿，把“张三的名字”查成“张三的电话号码”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;位置&lt;/strong&gt;：DNS 位于整个访问链路的最开始，用户输入网址时，浏览器必须先通过 DNS 得到 IP 才能发起请求&lt;/p&gt;
&lt;h3&gt;解析流程&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;用户在Web浏览器中输入 &lt;code&gt;example.com&lt;/code&gt; ， 向本地域名服务器发起查询请求。若本地域名服务器存在缓存的解析数据，则直接将域名对应的IP地址返回给Web浏览器，跳至步骤9。若本地域名服务器没有查到缓存的解析数据，则继续步骤2。&lt;/li&gt;
&lt;li&gt;本地域名服务器向根域名服务器进行查询。&lt;/li&gt;
&lt;li&gt;根域名服务器将 &lt;code&gt;.com&lt;/code&gt; 顶级域名服务器的地址，返回给本地域名服务器。&lt;/li&gt;
&lt;li&gt;本地域名服务器向 &lt;code&gt;.com&lt;/code&gt; 顶级域名服务器发起 &lt;code&gt;example.com&lt;/code&gt; 的查询请求。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.com&lt;/code&gt; 顶级域名服务器将为 &lt;code&gt;example.com&lt;/code&gt; 提供权威解析的权威域名服务器地址，返回给本地域名服务器。&lt;/li&gt;
&lt;li&gt;本地域名服务器向权威域名服务器发起查询请求。&lt;/li&gt;
&lt;li&gt;权威域名服务器将域名 &lt;code&gt;example.com&lt;/code&gt; 对应的 IP 地址，返回给本地域名服务器。&lt;/li&gt;
&lt;li&gt;本地域名服务器最后把查询的 IP 地址响应给 Web 浏览器。&lt;/li&gt;
&lt;li&gt;Web 浏览器通过 IP 地址访问网站服务器。&lt;/li&gt;
&lt;li&gt;网站服务器返回网页信息。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/5056354571/CAEQTxiBgIDntoSQjxkiIDllMmQyMGJlYzJlZjRlZWI4ZTAzYzI2ZWI3OGU2MjM13963382_20230830144006.372.svg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3&gt;相关概念&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;递归与迭代查询（非递归）查询&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;递归查询&lt;/strong&gt;：DNS 客户端（如你的电脑或手机）向 DNS 服务器发出请求，并要求该服务器必须返回最终结果。如果该服务器没有缓存答案，它就必须自己去“代表客户端”向其他服务器查询，直到获得最终的 IP 地址，然后返回给客户端。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;例如：你访问 &lt;code&gt;www.example.com&lt;/code&gt;，你的电脑向本地 DNS 服务器（如 8.8.8.8）发送一个递归查询请求，如果 8.8.8.8 没有缓存，它会自己去一步步查（通过迭代方式），最终把结果返回给你。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;本地域名服务器（递归 DNS 服务器），主要来源：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ISP（网络服务提供商）&lt;/strong&gt;：如电信、联通、移动等提供的 DNS 服务器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;公共 DNS 服务&lt;/strong&gt;：如 Google DNS（8.8.8.8）、Cloudflare（1.1.1.1）、阿里 DNS（223.5.5.5）、腾讯 DNS（119.29.29.29）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;企业/校园内部 DNS&lt;/strong&gt;：公司或学校自建的 DNS 服务器&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;迭代查询&lt;/strong&gt;： DNS 服务器不会替客户端继续查询，而是返回一个“可能知道答案”的其他 DNS 服务器地址，让客户端（或上游服务器）自己去问下一个服务器。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;TTL（Time To Live）&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;本地域名服务器（递归 DNS 服务器）在拿到 IP 后，会把这个结果缓存起来，TTL 就代表域名解析结果可缓存的最长时间，缓存时间到期后本地域名服务器则会删除该解析记录的数据，删除之后，如有用户请求域名，则会重新进行递归查询/迭代查询的过程。&lt;/li&gt;
&lt;li&gt;比如 TTL=600 → 递归 DNS 缓存 10 分钟，10 分钟后才会重新问权威 DNS。&lt;/li&gt;
&lt;li&gt;TTL 太长：改配置要等很久才生效。&lt;/li&gt;
&lt;li&gt;TTL 太短：查询太频繁，延迟增加。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;DNS 缓存&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;本地 DNS 缓存&lt;/strong&gt;：浏览器和操作系统会缓存 DNS 解析结果，减少重复查询。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;递归 DNS 缓存&lt;/strong&gt;：递归 DNS 服务器会缓存查询结果，减少对权威 DNS 的请求。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;记录类型&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;th&gt;使用场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;把域名解析到 IPv4 地址&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seeridia.top → 203.0.113.10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;网站直连服务器，最常见的解析方式&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AAAA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;把域名解析到 IPv6 地址&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seeridia.top → 2400:3200::1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;网站支持 IPv6 访问&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CNAME&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;把域名解析到另一个域名（别名）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www.seeridia.top → seeridia.top&lt;/code&gt;  &lt;code&gt;cdn.seeridia.top → cdn.edgeone.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CDN/ESA 接入、统一指向一个目标域名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;指定邮件服务器&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seeridia.top → mail.seeridia.top （优先级 10）&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gmail/Outlook 企业邮箱，自建邮箱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TXT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;存放文本信息（验证/策略）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seeridia.top TXT &quot;v=spf1 include:_spf.google.com ~all&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;域名验证、邮件安全（SPF/DKIM/DMARC）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;指定权威 DNS 服务器&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seeridia.top → ns1.dnsprovider.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;决定谁负责解析该域名（域名商 / DNS 服务商），一般用于将子域名交给其他 DNS 服务商解析&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;另外，还有一些其他类型的记录，如 SRV（服务定位）、PTR（反向解析）、CAA（签发证书）等，但不常用。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;CNAME 与 重定向 并不同&lt;/p&gt;
&lt;p&gt;CNAME 其实是说：这个域名其实是另一个域名的别名&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;CNAME 记录&lt;/th&gt;
&lt;th&gt;HTTP 重定向 (301/302)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;发生位置&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DNS 层&lt;/strong&gt;（浏览器还没发请求）&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;HTTP 层&lt;/strong&gt;（浏览器已经访问服务器）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;返回内容&lt;/td&gt;
&lt;td&gt;返回另一个域名，继续解析 IP&lt;/td&gt;
&lt;td&gt;返回新的 URL，让浏览器重新请求&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户是否可见&lt;/td&gt;
&lt;td&gt;对用户不可见，用户输入的域名不变&lt;/td&gt;
&lt;td&gt;用户地址栏会跳转到新的 URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;使用目的&lt;/td&gt;
&lt;td&gt;域名别名、接入 CDN、统一解析&lt;/td&gt;
&lt;td&gt;网站迁移、SEO、内容跳转&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/blockquote&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.CJNRvIBI.webp"/><category>website</category></item><item><title>Github Actions 自动化发布</title><link>https://blog.seeridia.top/blog/release-action</link><guid isPermaLink="true">https://blog.seeridia.top/blog/release-action</guid><description>以 构建 Tauri 应用 为例，使用 Github Actions 实现自动化发布</description><pubDate>Sun, 17 Aug 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我以下面这个 Tauri 应用为例，记录如何 Github Actions 实现自动化发布&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.seeridia.top/blog/release-action&quot;&gt;在原文中查看交互内容&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;我分成两部分，一个是每次 Commit 都自动构建应用，另一个是每次发布 Release 时自动打包应用。&lt;/p&gt;
&lt;h2&gt;推送构建&lt;/h2&gt;
&lt;p&gt;只要在主分支有代码提交，或发起 Pull Request，就会触发构建，产物放在 Actions 的 Artifacts 中&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;# .github/workflows/build-test.yml

name: Build and Test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        platform: [macos-latest, ubuntu-latest, windows-latest]

    runs-on: ${{ matrix.platform }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &apos;18&apos;
          cache: &apos;npm&apos;

      - name: Setup Rust
        uses: actions-rust-lang/setup-rust-toolchain@v1
        with:
          toolchain: stable

      - name: Install Linux dependencies
        if: runner.os == &apos;Linux&apos;
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            build-essential \
            curl \
            wget \
            file \
            libssl-dev \
            libgtk-3-dev \
            libwebkit2gtk-4.1-dev \
            libayatana-appindicator3-dev \
            librsvg2-dev

      - name: Install frontend dependencies
        run: npm install

      - name: Build Tauri application
        uses: tauri-apps/tauri-action@v0.5.22

      - name: Prepare artifact details
        id: prepare_artifact
        shell: bash
        run: |
          ARCH=$(echo &quot;${{ runner.arch }}&quot; | tr &apos;[:upper:]&apos; &apos;[:lower:]&apos;)
          ARTIFACT_PATH=&quot;&quot;
          ARTIFACT_NAME=&quot;&quot;

          if [ &quot;${{ runner.os }}&quot; == &quot;macOS&quot; ]; then
            ARTIFACT_PATH=$(find src-tauri/target/release/bundle/dmg -name &quot;*.dmg&quot; -print -quit)
            ARTIFACT_NAME=&quot;clipboard-viewer-latest-mac-${ARCH}.dmg&quot;
          elif [ &quot;${{ runner.os }}&quot; == &quot;Windows&quot; ]; then
            ARTIFACT_PATH=$(find src-tauri/target/release/bundle/msi -name &quot;*.msi&quot; -print -quit)
            ARTIFACT_NAME=&quot;clipboard-viewer-latest-win-${ARCH}.msi&quot;
          elif [ &quot;${{ runner.os }}&quot; == &quot;Linux&quot; ]; then
            ARTIFACT_PATH=$(find src-tauri/target/release/bundle/appimage -name &quot;*.AppImage&quot; -print -quit)
            ARTIFACT_NAME=&quot;clipboard-viewer-latest-linux-${ARCH}.AppImage&quot;
          fi
          
          echo &quot;Found artifact at: ${ARTIFACT_PATH}&quot;
          echo &quot;New artifact name: ${ARTIFACT_NAME}&quot;
          echo &quot;path=${ARTIFACT_PATH}&quot; &gt;&gt; $GITHUB_OUTPUT
          echo &quot;name=${ARTIFACT_NAME}&quot; &gt;&gt; $GITHUB_OUTPUT

      - name: Upload Build Artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ steps.prepare_artifact.outputs.name }}
          path: ${{ steps.prepare_artifact.outputs.path }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;发布构建&lt;/h2&gt;
&lt;p&gt;在推送 Release Tag 时，会自动触发构建并发布应用&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;# .github/workflows/release.yml

name: Create GitHub Release

on:
  push:
    tags:
      - &apos;v[0-9]+.[0-9]+.[0-9]+*&apos;

permissions:
  contents: write
  actions: read

jobs:
  publish-release:
    strategy:
      fail-fast: false
      matrix:
        platform: [macos-latest, ubuntu-latest, windows-latest]

    runs-on: ${{ matrix.platform }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &apos;18&apos;
          cache: &apos;npm&apos;

      - name: Setup Rust
        uses: actions-rust-lang/setup-rust-toolchain@v1
        with:
          toolchain: stable

      - name: Install Linux dependencies
        if: runner.os == &apos;Linux&apos;
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            build-essential \
            curl \
            wget \
            file \
            libssl-dev \
            libgtk-3-dev \
            libwebkit2gtk-4.1-dev \
            libayatana-appindicator3-dev \
            librsvg2-dev

      - name: Install frontend dependencies
        run: npm install

      - name: Build and publish release
        uses: tauri-apps/tauri-action@v0.5.22
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tagName: ${{ github.ref_name }}
          releaseName: &apos;App Version ${{ github.ref_name }}&apos;
          releaseBody: &apos;See the assets to download this version.&apos;
          releaseDraft: false
          prerelease: false
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;注意&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;无需配置 &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; 等，Github Actions 会自动提供。&lt;/li&gt;
&lt;li&gt;需要在 Settings &gt; Actions &gt; General &gt; Workflow permissions 中，选择 &quot;Read and write permissions&quot;&lt;/li&gt;
&lt;/ol&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.CQkEvmcx.webp"/><category>ci/cd</category><category>github actions</category></item><item><title>AstrBot + NapCat 下的 QQBot</title><link>https://blog.seeridia.top/blog/qqbot</link><guid isPermaLink="true">https://blog.seeridia.top/blog/qqbot</guid><description>得益于 AstrBot 和 NapCat 的强大功能，QQBot 的接入确实是非常方便了</description><pubDate>Sat, 16 Aug 2025 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;先贴出文档&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astrbot.app/&quot;&gt;AstrBot 文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://napcat.napneko.icu/&quot;&gt;NapCat 官网&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;得益于 AstrBot 和 NapCat 的强大功能，QQBot 的接入确实是非常方便了&lt;/p&gt;
&lt;h2&gt;NapCat 配置&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;安装 NapCat&lt;/p&gt;
&lt;p&gt;在 Linux 下可以直接使用官方的一键支持脚本&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt; curl -o \
 napcat.sh \
 https://nclatest.znin.net/NapNeko/NapCat-Installer/main/script/install.sh \
 &amp;#x26;&amp;#x26; sudo bash napcat.sh \
 --tui
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;按照指示安装即可，唯一要注意的是在 napcat 的日志里会显示 WebUI 链接，一般端口是 6099&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;启动&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt; # 启动 Napcat (需要图形环境或 Xvfb)
 sudo xvfb-run -a qq --no-sandbox


 # 后台启动 (使用 screen) (请使用 root 账户)
 #   启动
 screen -dmS napcat bash -c &quot;xvfb-run -a qq --no-sandbox&quot;

 #   带账号启动
 screen -dmS napcat bash -c &quot;xvfb-run -a qq --no-sandbox -q QQ号码&quot;

 #   附加到会话
 screen -r napcat

 #   停止会话
 screen -S napcat -X quit
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;配置&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;添加网络配置&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/napcat.v6QXEe0I_Z2aA95q.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如图配置&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/ws.qIALfrJn_Z2qDak7.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;至此 我们已经完成了 NapCat 的配置&lt;/p&gt;
&lt;h2&gt;AstrBot 配置&lt;/h2&gt;
&lt;p&gt;这边我直接在 1Panel 部署 AstrBot，因为其已经上架了 1Panel 的应用市场。&lt;/p&gt;
&lt;p&gt;开放 6158 端口后，我们直接进到 Web UI 进行配置&lt;/p&gt;
&lt;p&gt;在 &lt;strong&gt;消息平台 &gt; 新增适配器&lt;/strong&gt; 中选择 &lt;strong&gt;接入 QQ 个人号(aiocqhttp)&lt;/strong&gt;，按照下图配置即可&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/AstrBot.BcaqcoH1_l8tua.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;至此，我们已经完成了所有的配置，接下来可以在 AstrBot 的 Web UI 中进行测试和使用。&lt;/p&gt;
&lt;p&gt;这边附加一下，由于 Google 的相关 api 的免费额度很大，但是在国内服务器可能无法正常访问，这边有一个临时可用的反代：&lt;code&gt;https://dynamic-halva-76bb38.netlify.app/&lt;/code&gt;，可以直接代替原本的 API Base URL。&lt;/p&gt;
&lt;p&gt;可使用 &lt;code&gt;gemma-3-27b-it&lt;/code&gt;， 每天免费 14400 次。&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.B7T7_CUN.webp"/><category>tinkering</category></item><item><title>毛玻璃文字</title><link>https://blog.seeridia.top/blog/frosted-glass-text</link><guid isPermaLink="true">https://blog.seeridia.top/blog/frosted-glass-text</guid><description>对文字本身的颜色实现类似于 backdrop-filter 的毛玻璃效果</description><pubDate>Fri, 15 Aug 2025 23:00:00 GMT</pubDate><content:encoded>&lt;p&gt;主要矛盾很早就开始了，我想给文字本身的颜色实现类似于 &lt;code&gt;backdrop-filter&lt;/code&gt; 的毛玻璃效果，但这个效果在文字上并不能直接使用。（可恶）&lt;/p&gt;
&lt;p&gt;后续也问了蛮多认识的佬，大多都是改变透明度、混色方式等等去实现貌似是这样的效果，但都不是我想要的。&lt;/p&gt;
&lt;p&gt;最终想起了 stackoverflow ，还提了一个问题 （虽然最后被指出是问题重复，有人已经提过了，但是根本搜不到好不好 QwQ）&lt;/p&gt;
&lt;p&gt;这边先贴个链接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/questions/60141217/applying-a-backdrop-filter-blur-to-an-svg-path&quot;&gt;StackOverflow 讨论链接&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也先贴上解决方案：（在 stackoverflow 的基础上进行了改进）&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;img src=&quot;https://picsum.photos/id/99/500/300&quot;&gt;
&amp;#x3C;img class=&quot;clipped&quot; src=&quot;https://picsum.photos/id/99/500/300&quot;&gt;

&amp;#x3C;svg height=&quot;0&quot; width=&quot;0&quot;&gt;
    &amp;#x3C;defs&gt;
        &amp;#x3C;clipPath id=&quot;svgTextPath&quot;&gt;
            &amp;#x3C;text x=&quot;50&quot; y=&quot;200&quot; textLength=&quot;400px&quot; font-family=&quot;Vollkorn&quot; font-size=&quot;200px&quot; font-weight=&quot;700&quot;&gt; Text &amp;#x3C;/text&gt;
        &amp;#x3C;/clipPath&gt;
    &amp;#x3C;/defs&gt;
&amp;#x3C;/svg&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;body {
  margin 10px;
}

.clipped {
  position: absolute;
  top: 10px;
  left: 10px;
  clip-path: url(#svgTextPath);
  filter: blur(10px) brightness(1.2); // 调节模糊和亮度
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/example.BrYUt-zO_Z2lrrqu.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;是不是，超级无敌好的效果！&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;本帖就先这样放着，后续我可能会继续完善这个效果。&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.DdCNO23H.webp"/><category>frontend</category><category>css</category></item><item><title>从网站的上线开始讲起（一）</title><link>https://blog.seeridia.top/blog/website-launched</link><guid isPermaLink="true">https://blog.seeridia.top/blog/website-launched</guid><description>记录下自己的网站上线过程，也包含 GitHub Actions 下的自动化部署</description><pubDate>Fri, 15 Aug 2025 22:00:00 GMT</pubDate><content:encoded>&lt;p&gt;虽然标题叫做「从网站的上线开始讲起」，，但其实并不是或许你想象中的个人故事，毕竟我还啥都不会，也没什么经历和故事，所以，这章真的是——网站的上线&lt;/p&gt;
&lt;p&gt;本文主要涉及的是一个个人的上线工作流，包含 CI、CD 等等&lt;/p&gt;
&lt;h2&gt;Vercel&lt;/h2&gt;
&lt;p&gt;太过于美妙的前端部署平台，支持 Next.js、React 等几乎所有的框架。在 Vercel 中添加 Github 仓库，Vercel 会自动识别项目类型并进行构建。随后就可以直接设置相关的域名和环境变量等。比如你现在面前所看到的网站就是用 Vercel 部署的&lt;/p&gt;
&lt;p&gt;总结一下优缺点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：
&lt;ul&gt;
&lt;li&gt;简单易用：只需将代码推送到 GitHub，Vercel 会自动构建和部署&lt;/li&gt;
&lt;li&gt;支持多种框架：兼容 Next.js、React 等主流框架&lt;/li&gt;
&lt;li&gt;自动化：内置 CI/CD 流程，无需手动配置&lt;/li&gt;
&lt;li&gt;Free!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;缺点：
&lt;ul&gt;
&lt;li&gt;访问速度慢：在国内访问速度较慢，尤其是在某些地区（特别指出我所在的福建）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其实这一个缺点就足够致命了，尤其是对于需要频繁访问的项目，我对于重要的项目还是会选择部署在国内的服务器上。日后也会把这个博客放过去，目前在 Vercel 上的博客只是一个过渡。&lt;/p&gt;
&lt;h2&gt;Github Actions + 服务器部署&lt;/h2&gt;
&lt;p&gt;这个就麻烦多了&lt;/p&gt;
&lt;h3&gt;第一步：SSH 密钥&lt;/h3&gt;
&lt;p&gt;这个生成一次就好，之后就都用这个&lt;/p&gt;
&lt;h4&gt;1. 生成 SSH 密钥对&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t rsa -b 4096 -C &quot;github_actions_deploy_key&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接下来可以一直按回车，密钥会保存在默认位置 &lt;code&gt;~/.ssh/id_rsa&lt;/code&gt; 和 &lt;code&gt;~/.ssh/id_rsa.pub&lt;/code&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;id_rsa&lt;/code&gt;：&lt;strong&gt;私钥&lt;/strong&gt; (Private Key)，这个绝对不能泄露&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id_rsa.pub&lt;/code&gt;：&lt;strong&gt;公钥&lt;/strong&gt; (Public Key)，这个是用来配置在服务器上的&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2. 授权公钥&lt;/h4&gt;
&lt;p&gt;将生成的公钥 &lt;code&gt;id_rsa.pub&lt;/code&gt; 内容添加到服务器的 &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; 文件中。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 将公钥内容追加到 authorized_keys 文件中
cat ~/.ssh/id_rsa.pub &gt;&gt; ~/.ssh/authorized_keys

# 确保文件权限正确，否则 SSH 服务会拒绝连接
chmod 600 ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3. 获取私钥&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;cat ~/.ssh/id_rsa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;完整地复制&lt;/strong&gt;终端输出的所有内容，包括 &lt;code&gt;-----BEGIN RSA PRIVATE KEY-----&lt;/code&gt; 和 &lt;code&gt;-----END RSA PRIVATE KEY-----&lt;/code&gt; 这两行。&lt;/p&gt;
&lt;p&gt;这个就是服务器私钥。&lt;/p&gt;
&lt;h3&gt;第二步：服务器配置&lt;/h3&gt;
&lt;p&gt;我最早都是用的 Nginx 来部署静态网站，但是发现网站很多的时候，Nginx 的配置就会变得非常复杂，所以现在直接用 1Panel 来管理服务器 （其实是我不会，每次都叫 AI，确实有点麻烦了）&lt;/p&gt;
&lt;p&gt;使用 1Panel 来管理网站、证书什么的，那叫一个舒适啊，这边点几下就成。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.seeridia.top/_astro/1panel-website.BwTHey1u_1E2OVx.webp&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;这边要注意一下这边的目录：&lt;code&gt;/opt/1panel/www/sites/example.seeridia.top/index&lt;/code&gt;，我们待会配置 Github Actions 的时候要用到。&lt;/p&gt;
&lt;h3&gt;第三步：Github Actions&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;# .github/workflows/deploy.yml

# 工作流名称
name: Deploy to Production Server

# 触发条件：当有代码推送到 main 分支时
on:
  push:
    branches:
      - main

# 工作任务
jobs:
  build-and-deploy:
    # 运行环境：使用最新的 Ubuntu
    runs-on: ubuntu-latest

    steps:
      # 第一步：迁出代码
      - name: Checkout Code
        uses: actions/checkout@v4

      # 第二步：设置 Node.js 环境
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          # 使用与你服务器上相同的 Node.js 版本
          node-version: &apos;22&apos;

      # 第三步：安装依赖
      - name: Install Dependencies
        run: npm install 

      # 第四步：打包项目
      - name: Build Project
        run: npm run build

      # 第五步：部署到服务器
      - name: Deploy to Server
        uses: appleboy/scp-action@v1.0.0
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: &quot;dist/*&quot;
          target: ${{ secrets.TARGET_DIR }}
          strip_components: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;# .github/workflows/deploy.yml

# 工作流名称
name: Deploy to Production Server

# 触发条件：当有代码推送到 main 分支时
on:
  push:
    branches:
      - main

# 工作任务
jobs:
  build-and-deploy:
    # 运行环境：使用最新的 Ubuntu
    runs-on: ubuntu-latest

    steps:
      # 第一步：迁出代码
      - name: Checkout Code
        uses: actions/checkout@v4

      # 第二步：设置 Node.js 环境
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          # 使用与你服务器上相同的 Node.js 版本
          node-version: &apos;22&apos;
          cache: &apos;yarn&apos;

      # 第三步：安装依赖
      - name: Install Dependencies
        run: yarn install

      # 第四步：打包项目
      - name: Build Project
        run: yarn build

      # 第五步：部署到服务器
      - name: Deploy to Server
        uses: appleboy/scp-action@v1.0.0
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: &quot;dist/*&quot;
          target: ${{ secrets.TARGET_DIR }}
          strip_components: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时我们需要在 Github 仓库的 Secrets 中添加以下变量：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SERVER_HOST&lt;/code&gt;：服务器的 IP 地址&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SERVER_USER&lt;/code&gt;：服务器的用户名&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SSH_PRIVATE_KEY&lt;/code&gt;：服务器的 SSH 私钥（刚刚获取的那个）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TARGET_DIR&lt;/code&gt;：服务器上部署的目标目录&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;OK，就这样，非常轻松的，Github Actions 会在每次代码推送到 &lt;code&gt;main&lt;/code&gt; 分支时自动触发，执行上述步骤，将代码打包并部署到服务器上。&lt;/p&gt;</content:encoded><h:img src="https://blog.seeridia.top/_astro/cover.CHoLSPP-.webp"/><category>website</category><category>ci/cd</category><category>github actions</category></item></channel></rss>