<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Dupfioire</title><description>的回收站</description><link>https://blog.erio.work/</link><language>zh_CN</language><item><title>这周看了什么[2]</title><link>https://blog.erio.work/posts/%E8%BF%99%E5%91%A8%E7%9C%8B%E4%BA%86%E4%BB%80%E4%B9%882/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%BF%99%E5%91%A8%E7%9C%8B%E4%BA%86%E4%BB%80%E4%B9%882/</guid><description>那总得干点啥吧</description><pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;https://github.com/Majorfi/immich-places&lt;/p&gt;
&lt;p&gt;部署了这个，装这个的主要原因是想修复一些照片的拍摄位置，Immich 本身也有大地图，但无法编辑，只能浏览，调整经纬度得在每张照片的小弹窗上编辑。&lt;/p&gt;
&lt;p&gt;Immich Places 支持拖动编辑，左侧筛选出没有拍摄位置的图片，拖到地图上就能自动生成经纬度，然后同步到 Immich 上。&lt;/p&gt;
&lt;p&gt;另外一提，Immich Places 的 UI 做的很好看，Immich 相较之下太保守了，纯纯的 Material Design。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;https://www.therantydev.com/javascript-drms-are-stupid&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;DRM 是什么？DRM 是一种技术，用于保护数字媒体内容，防止未经授权的复制、分发和修改，DRM 通常用于音乐、视频、软件和其他数字媒体。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这篇文章主要就一个思路，不管你加密手段多高明，到浏览器上播放的时候，用的还得是解密后的数据。&lt;/p&gt;
&lt;p&gt;除非用的是浏览器中自带的相关“加密”特性，让解密模块存在于浏览器内部，JS 碰不到，否则像 HotAudio 这种自己开发的毫无意义。&lt;/p&gt;
&lt;p&gt;浏览器播放流媒体也是一样的道理，你可以把视频作为流的格式去传输，比如常见的 &lt;code&gt;&apos;m3u8&apos;&lt;/code&gt;，但大多浏览器本身的 &lt;code&gt;video&lt;/code&gt; 元素还是无法直接支持流媒体，还得用 JS 去下载分片，然后依靠 &lt;code&gt;Media Source Extensions&lt;/code&gt; 的相关 API 去组装数据，最后再让浏览器播放。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;https://barvian.me/react-exit-animations&lt;/p&gt;
&lt;p&gt;其实 exit animation 这块很多组件库都没好好处理，我是感觉这种东西组件库应该是得做好，而不是让开发者自己去处理。&lt;/p&gt;
&lt;p&gt;Mantine 的 Modal 提供了 onAnimationEnd 回调，所以要实现可以在动画结束的清空表单，把关闭动作和动画动作分开，但自己处理总是有些麻烦。&lt;/p&gt;
&lt;p&gt;就看这个文章提供的代码吧，先说 &lt;code&gt;Suspense&lt;/code&gt; 组件，它是 React 自带的，用在异步组件的加载优化上的，功能大概是这样，他子组件“应该”是个 &lt;code&gt;Promise&lt;/code&gt; 异步组件，如果子组件处在被挂起状态，那么它就会显示 &lt;code&gt;fallback&lt;/code&gt; 中的内容，相反就表示异步组件加载完成，可以正常显示。&lt;/p&gt;
&lt;p&gt;但 &lt;code&gt;Suspense&lt;/code&gt; 有个特性，如果处在被挂起状态，那么子组件会被隐藏，但他的隐藏是通过 &lt;code&gt;display none&lt;/code&gt; 实现的，换句话说，他只是&lt;strong&gt;不可见&lt;/strong&gt;，但 DOM 结构还是存在的，并且还比较&lt;strong&gt;特殊&lt;/strong&gt;，子组件状态依然会更新，但此时 React 不会去更新他的 DOM 结构。&lt;/p&gt;
&lt;p&gt;这个正是我们需要的，组件正常更新（不影响应用），DOM 被冻结了。现在只差一个问题，&lt;strong&gt;子组件被隐藏起来了&lt;/strong&gt;，所以这段代码的第二个功能就是想办法找到这个被隐藏的组件，然后将它的样式进行调整，把 &lt;code&gt;display none&lt;/code&gt; 给去掉，展现一个快照，即便现在组件状态改变了，表单数据被清空了，在用户看来内容还是没变过。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;https://www.canirun.ai/&lt;/p&gt;
&lt;p&gt;一个检测电脑能跑哪些本地模型的工具。&lt;/p&gt;
</content:encoded></item><item><title>解决 Open WebUI 更新后无法启动的问题</title><link>https://blog.erio.work/posts/%E8%A7%A3%E5%86%B3open-webui%E6%9B%B4%E6%96%B0%E5%90%8E%E6%97%A0%E6%B3%95%E5%90%AF%E5%8A%A8%E7%9A%84%E9%97%AE%E9%A2%98/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%A7%A3%E5%86%B3open-webui%E6%9B%B4%E6%96%B0%E5%90%8E%E6%97%A0%E6%B3%95%E5%90%AF%E5%8A%A8%E7%9A%84%E9%97%AE%E9%A2%98/</guid><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;在任何危险操作前都请先备份！&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such column: user.scim
[SQL: SELECT count(*) AS count_1
FROM (SELECT user.id AS user_id, user.email AS user_email, user.username AS user_username, user.role AS user_role, user.name AS user_name, user.profile_image_url AS user_profile_image_url, user.profile_banner_image_url AS user_profile_banner_image_url, user.bio AS user_bio, user.gender AS user_gender, user.date_of_birth AS user_date_of_birth, user.timezone AS user_timezone, user.presence_state AS user_presence_state, user.status_emoji AS user_status_emoji, user.status_message AS user_status_message, user.status_expires_at AS user_status_expires_at, user.info AS user_info, user.settings AS user_settings, user.oauth AS user_oauth, user.scim AS user_scim, user.last_active_at AS user_last_active_at, user.updated_at AS user_updated_at, user.created_at AS user_created_at
FROM user) AS anon_1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;手欠了，更新 open-webui，更新出问题了，docker 查询容器日志，发现是数据库无法更新，以上是错误。&lt;/p&gt;
&lt;p&gt;简单来说就是新版本给 &lt;code&gt;user&lt;/code&gt; 表加了一列 &lt;code&gt;scim&lt;/code&gt;，查了下应该是身份验证方面的东西，根据 &lt;a href=&quot;https://github.com/open-webui/open-webui/issues/16800&quot;&gt;issue&lt;/a&gt; 里好心人说的方法，可以用 python 代码去做数据库迁移，但我试了无效，和同 issue 里面那人的结果一样，&lt;code&gt;Required environment variable not found. Terminating now&lt;/code&gt;（其他方法我也试了，都无效）。&lt;/p&gt;
&lt;p&gt;所以应该不是迁移上出了问题，估计是权限了，但之前使用也没啥事情，文档里也没提到要创建高权限用户，所以我就不整活了，直接去查看数据库，手动更新吧。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260223-191713.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;检查数据表后发现，确实没有 &lt;code&gt;scim&lt;/code&gt; 列，那么 &lt;code&gt;scim&lt;/code&gt; 是什么类型呢？去&lt;a href=&quot;https://github.com/open-webui/open-webui/blob/1ac3dd4a893e13803e7b889611303c4a7a5cc470/backend/open_webui/models/users.py#L74&quot;&gt;源码&lt;/a&gt;搜 &lt;code&gt;user.scim&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;可以看到和 &lt;code&gt;oauth&lt;/code&gt; 一样，是 &lt;code&gt;JSON&lt;/code&gt; 类型，但我印象中的 sqlite 没有这个类型（可能是新版本），不过问题不大，&lt;code&gt;oauth&lt;/code&gt; 能用 &lt;code&gt;scim&lt;/code&gt; 也能用！
&lt;code&gt;ALTER TABLE user ADD COLUMN scim JSON;&lt;/code&gt;
直接执行 alter，接着重启容器测试。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260223-192012.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;OK，问题解决&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260223-192038.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>这周看了什么 [1]</title><link>https://blog.erio.work/posts/%E8%BF%99%E5%91%A8%E7%9C%8B%E4%BA%86%E4%BB%80%E4%B9%881/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%BF%99%E5%91%A8%E7%9C%8B%E4%BA%86%E4%BB%80%E4%B9%881/</guid><description>那总得干点啥吧</description><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;https://suhaotian.github.io/broad-infinite-list/?demo=logs&lt;/p&gt;
&lt;p&gt;虚拟滚动，我刚工作的时候也做过类似的，无非就是计算项目高度，然后预留出前后的空间，再去计算当前要显示的部分。&lt;/p&gt;
&lt;p&gt;但有一点我到现在都无法理解，那就是项目高度不确定的情况，或者说无法接受吧，因为大家在处理这种情况时，会先“猜测”一个高度，然后用上去，等真正的项目渲染后，再固定他的真实高度
我能理解这种解法，但这也意味着滚动条的长度不是真实的，用的时候长度一直在变，越滚越多，这很诡异好吧。。。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;https://json-render.dev/&lt;/p&gt;
&lt;p&gt;算是个好东西，也是我一直在项目中推进的，在 AI 时代就该好好用这种 DSL，灵活可变，教会他们规范，就能直接生成界面，省去很多重复性工作。&lt;/p&gt;
&lt;p&gt;但很明显，&lt;code&gt;type&lt;/code&gt; 对应的基础组件还是要自己开发的，组件如何保持灵活性，如何保证“能复用的场景多过重新开发的场景”是个问题，我前前公司就深陷这个泥潭，运营的点子比基础组件多，每次做新营销活动都得开发组写一套新的组件，配置比重写更麻烦。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;https://frankentui.com/web_react&lt;/p&gt;
&lt;p&gt;这个我暂时不理解他的使用场景，但就 TUI 来说，我自己也研究过一些。&lt;/p&gt;
&lt;p&gt;一句话，比想象中麻烦，为了“自动在合适的位置显示合适的字符”，要做的处理有很多，你以为你在开发一个字符组件库，但你在开发的实际上是浏览器。&lt;/p&gt;
&lt;p&gt;从我学到的部分讲起吧，相关的概念有组件、布局、渲染、事件等。&lt;/p&gt;
&lt;h2&gt;万物皆组件&lt;/h2&gt;
&lt;p&gt;能看到的输入框、卡片、列表都是组件，组件有自己的状态，比如 &lt;code&gt;Label&lt;/code&gt; 的文本内容、字色，&lt;code&gt;Input&lt;/code&gt; 的值等。&lt;/p&gt;
&lt;p&gt;有自己的尺寸、位置信息，同时也要能从上级接受配置。&lt;/p&gt;
&lt;p&gt;能管理自己的渲染方式，也就是如何将状态绘制到“缓冲区”中。&lt;/p&gt;
&lt;h2&gt;布局管理&lt;/h2&gt;
&lt;p&gt;布局也是个组件，但比较特殊，他的职责不是渲染自己，而是计算他子组件的尺寸、位置信息，可以理解成 CSS 中的 &lt;code&gt;display&lt;/code&gt; 属性。&lt;/p&gt;
&lt;p&gt;所以组件设计上要支持嵌套。&lt;/p&gt;
&lt;h2&gt;渲染管线&lt;/h2&gt;
&lt;p&gt;不能直接操作终端，因为一边写入一边显示，会导致闪烁，所以需要缓冲区，将组件的渲染结果写入缓冲区，再一次性显示。&lt;/p&gt;
&lt;p&gt;可以理解成 &lt;code&gt;console.log&lt;/code&gt;，多次频繁打印和一次打印所有内容的效率是不同的。&lt;/p&gt;
&lt;p&gt;这里的缓冲区也有讲究，应该创建两个缓冲区，一个用于当前渲染，另一个用于下一帧渲染，这样在下一帧渲染时，可以立即将当前帧的缓冲区内容显示到终端，而不用等待当前帧渲染完成。&lt;/p&gt;
&lt;p&gt;再进一步，可以遍历组件树，判断哪些组件需要更新，哪些组件不需要更新，再差分写入到缓冲区，以提高效率。&lt;/p&gt;
&lt;p&gt;做着做着就会发现，这不就是重写了一个 React、Vue 的核心吗？其实也没错，参考 &lt;a href=&quot;https://github.com/vadimdemedes/ink&quot;&gt;Ink&lt;/a&gt;，他就借助了 React 核心的能力，重新实现一个 Renderer。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;https://dpaint.app/&lt;/p&gt;
&lt;p&gt;一个仿 Photoshop 的图像编辑器，PS 还是很好用很全面的，但为了改一两个地方下一个 PS 属实不值得。&lt;/p&gt;
&lt;p&gt;我之前用的是 &lt;a href=&quot;https://www.photopea.com/&quot;&gt;Photopea&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;两者在功能上没啥区别了，前者开源后者闭源，后者靠广告和云存储赚钱，也没啥大碍。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;https://adesignl.github.io/Peek/&lt;/p&gt;
&lt;p&gt;一个小巧的页面头部“动画”库，我不知道该怎么描述他，点进去看效果就明白了。&lt;/p&gt;
&lt;p&gt;本质就是监听 &lt;code&gt;wheel&lt;/code&gt;、&lt;code&gt;scroll&lt;/code&gt; 事件判断方向，确定头部是要固定还是隐藏，但他容差检查做的不错，一些小幅度滚动不会导致头部变化，用着挺舒服。&lt;/p&gt;
</content:encoded></item><item><title>告别 Leanote：一次艰难的笔记备份之旅</title><link>https://blog.erio.work/posts/%E5%91%8A%E5%88%ABleanote/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%91%8A%E5%88%ABleanote/</guid><pubDate>Thu, 04 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;掐指一算，从大学时代开始使用 Leanote 至今，已经有近十年了，它曾是我学习和记录想法的重要工具。&lt;/p&gt;
&lt;p&gt;然而，这款应用已经数年没有更新，官网也透露着一种随时可能停止服务的气息，为了防止数据丢失，我决定将所有笔记备份出来，并彻底离开这个平台。&lt;/p&gt;
&lt;p&gt;我本想“和平分手”，利用官方提供的导出功能来解决问题，但在一番寻找后，我发现 Leanote 的网页版根本没有提供任何形式的批量导出功能，看官方博客的说法，这个功能似乎在客户端中，我本想下载的，但查了一下相关的条例，免费账户导出的 PDF 会带有水印，对于数据迁移来说，显然不是一个理想的方案，因为我只想要原始数据，不需要“帮”我打包。&lt;/p&gt;
&lt;p&gt;于是我转而查询它的 API 文档，希望能通过写脚本的方式，将笔记完整地导出来，它在 Github 上提供了相关文档（虽然这文档也有快十年没更新了），我花了几分钟，很快写好了一套简单的脚本。然而，在执行到“获取所有笔记本”（getNotebooks）这一步时，程序就卡住了，接口返回的错误信息是“NEED-UPGRADE-ACCOUNT”。&lt;/p&gt;
&lt;p&gt;这时我才意识到问题的根源，我这个一直都是免费版的账户，不知何时被动地开始了一段“试用会员”，现在每次登录都会提示我会员已到期。原来，不付费升级，我甚至没有通过 API 读取自己笔记列表的权限，自己的数据，想要用一种高效的方式取出来，竟然需要先付费，何意味？&lt;/p&gt;
&lt;p&gt;在研究 API 的过程中，我还发现了另一个不安全的设计，它文档中的登录接口竟然是 GET 请求，这意味着账户和密码会作为查询字符串（query string）直接拼接在 URL 中进行传输，虽然 HTTPS 能够加密请求体，但 URL 本身在传输链路的许多中间节点中，都可能以明文形式被记录下来，将敏感的登录凭证以这种方式暴露，是一个相当基础且严重的安全疏忽。&lt;/p&gt;
&lt;p&gt;最终，所有“技术”和“官方”的道路都被堵死了，我只能选择最原始、最耗时的方式：手动备份。我的笔记数量不算特别多，几十篇而已，但一篇篇地打开、复制、粘贴到新的笔记应用中，再处理格式问题，依然耗费了我好几个小时。&lt;/p&gt;
&lt;p&gt;完成这次备份后，我便会去注销掉 Leanote 的账户，一段始于大学时代的数字记忆，以这样一种不算愉快的方式画上了句号，再也不见，Leanote。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PS&lt;/strong&gt;：我查看了其他开发者做的备份工具，发现他们用的和我也是同一套接口，推测免费调用是过去式，付费限制是后更新加入的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PS2&lt;/strong&gt;：Web 项目专门避开了这套 API，直接将笔记本列表数据注入到了 HTML 中，后续的内容获取是通过另一套 API 实现的，但我已经没有必要去研究了。&lt;/p&gt;
</content:encoded></item><item><title>Deno 上手体验：一些好用的地方和需要注意的点</title><link>https://blog.erio.work/posts/deno%E4%B8%8A%E6%89%8B%E4%BD%93%E9%AA%8C%E5%88%86%E4%BA%AB/</link><guid isPermaLink="true">https://blog.erio.work/posts/deno%E4%B8%8A%E6%89%8B%E4%BD%93%E9%AA%8C%E5%88%86%E4%BA%AB/</guid><description>我会把我遇到的几个关键点列出来，包括一些好用的地方和几个比较“坑”的体验，给大家一个参考，如果以后有项目要用，可以少走点弯路。</description><pubDate>Mon, 27 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我最近在试用 Deno，就是那个 Node 开发者为 JS 弄的新运行时。&lt;/p&gt;
&lt;p&gt;总的来说，Deno 是一个很有意思的运行时，它在很多地方简化了开发流程，但也带来了一些新的概念和需要注意的地方。我会把我遇到的几个关键点列出来，包括一些好用的地方和几个比较“坑”的体验，给大家一个参考，如果以后有项目要用，可以少走点弯路。&lt;/p&gt;
&lt;h1&gt;1. 内置功能很方便，能大量减少模板代码&lt;/h1&gt;
&lt;p&gt;这是我上手 Deno 后感觉最舒服的一点，在 Node 里，我习惯了做什么事都先 &lt;code&gt;pnpm install&lt;/code&gt;，起码要把基础功能的包给安装上，但 Deno 把很多常用功能直接内置到了全局的 &lt;code&gt;Deno&lt;/code&gt; 对象里。&lt;/p&gt;
&lt;p&gt;举两个例子：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Web 服务器&lt;/strong&gt;：想启动一个 HTTP 服务，直接用 &lt;code&gt;Deno.serve&lt;/code&gt; 就行了。&lt;pre&gt;&lt;code&gt;// main.ts
Deno.serve((_req) =&amp;gt; new Response(&quot;Hello, World!&quot;));
&lt;/code&gt;&lt;/pre&gt;
一行代码搞定，不需要安装 Express 或其他框架。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;键值对数据库&lt;/strong&gt;：Deno 内置了一个开箱即用的 KV 数据库 &lt;code&gt;Deno.Kv&lt;/code&gt;。
对于一些需要轻量级数据持久化的小项目或原型，这个功能非常实用，省去了配置和连接外部数据库的步骤。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;2. 生态兼容性：JSR、NPM 和 Node API&lt;/h1&gt;
&lt;p&gt;Deno 在努力兼容 Node 生态，但这里有几个点需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Node 内置库不完全兼容&lt;/strong&gt;：我一开始以为 Deno 能无缝使用 Node 的所有内置库（比如 &lt;code&gt;fs&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;），实际上，它只兼容了一部分，使用时需要加上 &lt;code&gt;node:&lt;/code&gt; 前缀，比如 &lt;code&gt;import fs from &quot;node:fs&quot;&lt;/code&gt;。具体哪些 API 兼容，需要去查官方文档，不能想当然。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JSR 和 NPM 是两回事&lt;/strong&gt;：JSR 是 Deno 官方的包注册表，而 NPM 是 Node 的。虽然 Deno 支持通过 &lt;code&gt;npm:&lt;/code&gt; 前缀直接安装和使用 NPM 包（比如 &lt;code&gt;import express from &quot;npm:express&quot;&lt;/code&gt;），但这并不意味着所有包都能正常工作，如果一个 NPM 包依赖了某个 Deno 未兼容的 Node API，运行时还是会报错。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;3. 核心设计：强大的安全沙箱（以及它带来的大坑）&lt;/h1&gt;
&lt;p&gt;Deno 的一个核心理念是“默认安全”，你的代码在没有显式授权的情况下，什么都干不了，比如不能读写文件，不能访问网络，也不能读取环境变量。&lt;/p&gt;
&lt;p&gt;与其说它是个运行时，不如说它更像一个沙箱，你需要通过命令行标志来给程序授权（写在 &lt;code&gt;deno.jsonc&lt;/code&gt; 中也行）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--allow-read&lt;/code&gt;：允许读取文件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--allow-net&lt;/code&gt;：允许访问网络&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--allow-write&lt;/code&gt;：允许写入文件&lt;/li&gt;
&lt;li&gt;...其他的需要查看文档&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个设计理念本身没有问题，能有效控制代码权限，防止潜在的安全风险，问题出在他的默认行为上，让我踩了大坑。&lt;/p&gt;
&lt;p&gt;我的程序里有一段很简单的 &lt;code&gt;fetch&lt;/code&gt; 网络请求代码，每次运行时，程序跑到 &lt;code&gt;fetch&lt;/code&gt; 那里就直接&lt;strong&gt;挂起&lt;/strong&gt;了——没有报错，没有超时，没有任何提示，就是卡住不动了。&lt;/p&gt;
&lt;p&gt;我花了很多时间去排查网络、代码逻辑，甚至怀疑是 Deno 的 bug，最后才发现，根本原因是我没有在启动命令里加上 &lt;code&gt;--allow-net&lt;/code&gt; 来授予网络权限。&lt;/p&gt;
&lt;p&gt;我完全能理解这种安全限制，但&lt;strong&gt;我无法接受这种用“静默挂起”来代替“明确报错”的默认行为&lt;/strong&gt;。如果它能直接抛出一个 &lt;code&gt;PermissionDenied&lt;/code&gt; 之类的错误，比起挂起，更能让用户或者开发者意识到，对于新手来说，这种体验非常糟糕。&lt;/p&gt;
&lt;h1&gt;总结&lt;/h1&gt;
&lt;p&gt;最后总结一下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;内置 API&lt;/strong&gt;：对于新项目，先看看 &lt;code&gt;Deno&lt;/code&gt; 全局对象里有没有现成的功能，可以省去不少功夫&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;谨慎对待兼容性&lt;/strong&gt;：不要默认所有 Node 的东西都能在 Deno 里跑，使用前最好先查一下文档&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意权限问题&lt;/strong&gt;：如果你的程序（特别是涉及 IO、网络的部分）莫名其妙地挂起或不工作，&lt;strong&gt;第一反应应该是去检查标记，而不要先怀疑自己的业务逻辑&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>关于照片迁移与分类</title><link>https://blog.erio.work/posts/%E5%85%B3%E4%BA%8E%E7%85%A7%E7%89%87%E8%BF%81%E7%A7%BB%E4%B8%8E%E5%88%86%E7%B1%BB/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%85%B3%E4%BA%8E%E7%85%A7%E7%89%87%E8%BF%81%E7%A7%BB%E4%B8%8E%E5%88%86%E7%B1%BB/</guid><pubDate>Mon, 06 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我这几天将照片从群晖 Photos 迁移到了 Immich 上，最终还是想好好地将图片分个类，便于以后的查找。&lt;/p&gt;
&lt;p&gt;首先介绍一下群晖 Photos 的分类方式吧，他没有标签，只有相册，相册是基于真实目录划分的，好处就是没有元数据，方便迁移，目录一存一移就好了，那不好的地方也在于缺失元数据。&lt;/p&gt;
&lt;p&gt;他这种基于真实目录的相册，只能进行单一维度的划分，因为照片文件只能存在于一个目录中，当然了，可以复制粘贴到不同目录，但这样设备中就会存在同一张照片的多个备份，这很明显不利于归档。&lt;/p&gt;
&lt;p&gt;举个例子，你有一张在初中运动会上的照片，这张照片你可以拆解出以下的属性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;xx 体育场（地点）&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;这几个属性在分类上是不相交的，所以他们很自然地会作为独立的目录，这时候你的照片应该放在哪呢？选一个联系最强的？还是复制 4 份保存？答案是都不好。&lt;/p&gt;
&lt;p&gt;可扩展的分类至少应该有标签概念，标签和照片是多对多关系，一个标签可以贴在多张照片上，反过来，一张照片也能具有多个标签，用标签来确定照片的属性，这样不影响照片的实际存放位置。&lt;/p&gt;
&lt;p&gt;但要做到这一点，软件就必须得有元数据，你可以理解成用一份文档去记录照片和标签之间的关系，Immich 可以做到这一点。&lt;/p&gt;
&lt;p&gt;Immich 有相册和标签两种概念，相册是虚拟的，不依赖真实目录，不可以嵌套，标签也是虚拟的，但标签可以嵌套，用标签来表示照片属性，那么相册应该用来干什么呢？我认为相册应该用来表示&lt;strong&gt;有始有终的事件&lt;/strong&gt;或者是&lt;strong&gt;一套精选集&lt;/strong&gt;，是由事件、主题驱动的。&lt;/p&gt;
&lt;p&gt;由于标签是可以嵌套的，所以标签上面的分类就比较有讲究了，也因为标签是要应对未来情况的，所以必须在开始前就规划好，在照片多起来后修改就会很麻烦。&lt;/p&gt;
&lt;p&gt;这里我是按照几个 W 来进行基础父级标签划分的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Who，比如家人、朋友&lt;/li&gt;
&lt;li&gt;Where，比如学校、公司&lt;/li&gt;
&lt;li&gt;What，比如美食、散步&lt;/li&gt;
&lt;/ol&gt;
&lt;p&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;li&gt;动物&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这里更多的是额外的属性和无法归类到三 W 中的那些，但动物比较特殊，他同属于 Who 和 What，同时我从猫狗、鸟，到昆虫都会拍，所以我决定将他提出来，单独作为一个大类。&lt;/p&gt;
&lt;p&gt;为什么说他好扩展呢？因为最高层级都是一些非常抽象的概念，而往下具体的分类都是子级，如果有新的分类，都能归纳到其中一个大类里。&lt;/p&gt;
</content:encoded></item><item><title>Cloudflare 令牌申请时的坑</title><link>https://blog.erio.work/posts/cloudflare%E4%BB%A4%E7%89%8C%E7%94%B3%E8%AF%B7%E6%97%B6%E7%9A%84%E5%9D%91/</link><guid isPermaLink="true">https://blog.erio.work/posts/cloudflare%E4%BB%A4%E7%89%8C%E7%94%B3%E8%AF%B7%E6%97%B6%E7%9A%84%E5%9D%91/</guid><description>记录群晖 SSL 证书申请时的流程和使用 Cloudflare DNS 认证时遇到的坑</description><pubDate>Fri, 03 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;起因&lt;/h1&gt;
&lt;p&gt;先写一下起因吧，随便给我自己记录一下群晖 SSL 申请的流程。&lt;/p&gt;
&lt;p&gt;昨天我给家里的群晖上了 SSL，这里和其他域名申请证书的流程是差不多的，都是在机构注册申请证书，然后替换到要使用的主机上。&lt;/p&gt;
&lt;p&gt;Let&apos;s encrypt 是免费的证书发行机构，但代价就是每三个月要重新续一次，让我手动续期，那我会当场表演一个失忆，这里肯定会选择自动化方案的，其中最知名的项目就是 &lt;a href=&quot;https://github.com/acmesh-official/acme.sh/wiki/%E8%AF%B4%E6%98%8E&quot;&gt;acme.sh&lt;/a&gt;，他能用命令处理申请、复制、更新的流程。&lt;/p&gt;
&lt;p&gt;群晖也一样，群晖有内置证书，你得把新的换进去，并且替代他，才能让群晖默认使用新证书，但问题在于，群晖的证书路径是随机的，根目录一样，都是 &lt;code&gt;/usr/syno/etc/certificate/_archive&lt;/code&gt;，但子目录名称不同，你需要 &lt;code&gt;cat DEFAULT&lt;/code&gt; 查看内容，确定目录名，这一点光凭 acme.sh 是做不到的。&lt;/p&gt;
&lt;p&gt;所以这里我使用的是 &lt;a href=&quot;https://github.com/andyzhshg/syno-acme&quot;&gt;syno-acme&lt;/a&gt; 这个自动化脚本，虽然用的是 2.8.6 的 acme.sh，但问题不大，他项目中的 python 代码就是用来查询“随机目录名”的，将 acme.sh 申请到的证书拿去指定路径替换默认证书。&lt;/p&gt;
&lt;h1&gt;问题&lt;/h1&gt;
&lt;p&gt;我域名挂靠在 Cloudflare 上，所以脚本配置上用的就是 &lt;code&gt;export DNS=dns_cf&lt;/code&gt;，如果你也用这个，剩余的参数可以到&lt;a href=&quot;github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_cf&quot;&gt;文档&lt;/a&gt;处查询。&lt;/p&gt;
&lt;p&gt;如果你恰好使用的是令牌认证，那么你或许会遇到这样的报错信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Thu Oct  2 17:10:11 CST 2025] Adding txt value: XXX for domain:  _acme-challenge.foo.com
[Thu Oct  2 17:10:13 CST 2025] invalid domain
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当你确保所有权限都按文档上的设置了还是报错，那么很大概率是这里填错了：
&lt;img src=&quot;./b79eebf9da9045413c91078c189f87a5.png&quot; alt=&quot;图 1&quot; /&gt;
这是默认的表单，运算符可以选择“在”或“不在”，进一步筛选。
&lt;img src=&quot;./20c0016177b0ba082cb53963ca3bd0ca.png&quot; alt=&quot;图 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;他坑就坑在，但凡你选过了其中一个运算符，你都无法将其删除，变成了要么“在”，要么“不在”，黑白名单你必须选一个，所以你这个令牌就“废”了（你就得去确认设备出口时的 IP，并且保证不变）。&lt;/p&gt;
&lt;p&gt;正确的做法就是重新创建令牌，然后跳过这一行，什么都不选，什么都别碰，这样创建出来的就不会进行 IP 筛选，表示所有都放行，无黑白名单。&lt;/p&gt;
</content:encoded></item><item><title>Upload Simulator 的布局计算器</title><link>https://blog.erio.work/posts/uploadsimulator%E7%9A%84%E5%B8%83%E5%B1%80%E8%AE%A1%E7%AE%97%E5%99%A8/</link><guid isPermaLink="true">https://blog.erio.work/posts/uploadsimulator%E7%9A%84%E5%B8%83%E5%B1%80%E8%AE%A1%E7%AE%97%E5%99%A8/</guid><pubDate>Mon, 08 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这段时间沉迷 upload simulator 2，在移动端上，这是这几年少有的&lt;strong&gt;无内购的挂机游戏&lt;/strong&gt;，是的，挂机游戏现在也搞起了内购，很诡异，在一个本来就需要堆叠时间，甚至核心玩法就是付出时间的游戏里，可以用金钱去加速，用自己的金钱买自己的时间，换来的是更快的数字滚动。&lt;/p&gt;
&lt;p&gt;这个游戏我之前在安卓上玩了一下，&lt;strong&gt;安卓平台上的版本是免费的&lt;/strong&gt;，所以他也是一个&lt;strong&gt;内购挂机&lt;/strong&gt;游戏，我当时的感受是，我靠这什么挂机游戏，为什么点击要素那么多，还有能量限制不能自动点，玩个毛！&lt;/p&gt;
&lt;p&gt;这几天恰巧在 iOS 上看到了，标了价格，大概 30 块，有了买断机制，那么就不会出现内购了，变成了“纯血”挂机游戏。&lt;/p&gt;
&lt;p&gt;经过深入摸索，游戏刚开始确实更像一个点击类挂机游戏，但点击不是必要的，当玩到一定程度，大概重生两三回吧，就能解锁插件，插件里面有个挂机专用的，叫&lt;strong&gt;催化剂&lt;/strong&gt;，会把系统效率提升 N 倍，但副作用是让能量归零，无法获得点击收益，这就是挂机专用的原因，你不能操作了，但换来的是极高的挂机效率。&lt;/p&gt;
&lt;p&gt;要搓出这个插件不简单，需要不少零件，好在零件也是可以挂机获得的，又或者是你 RP 非常好，从挂机宝箱里直接抽到了这个芯片。我就是这样，抽了三次就出了，接着这游戏的点击玩法就被我遗忘了，再也没有开启过。&lt;/p&gt;
&lt;p&gt;之后还会解锁一个功能，工厂，有多种发电设施，每个提供的倍率、温度都是不一样的，你需要解锁空间去放置他们，组合出最大化的倍率，因为他们占的空间不一致，所以不要看一些设施倍率很高，但考虑到空间占用多，使用后效果还不一定有低级的好。&lt;/p&gt;
&lt;p&gt;那么如何组合出最优解，这就是个算法问题了，所以我让 AI 出了一个计算器：&lt;/p&gt;
&lt;p&gt;&amp;lt;p class=&quot;codepen&quot; data-height=&quot;600&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;GgpeOdL&quot; data-pen-title=&quot;power calc&quot; data-user=&quot;erioifpud&quot; style=&quot;height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&amp;gt;
&amp;lt;span&amp;gt;See the Pen &amp;lt;a href=&quot;https://codepen.io/erioifpud/pen/GgpeOdL&quot;&amp;gt;
power calc&amp;lt;/a&amp;gt; by Erioifpud (&amp;lt;a href=&quot;https://codepen.io/erioifpud&quot;&amp;gt;@erioifpud&amp;lt;/a&amp;gt;)
on &amp;lt;a href=&quot;https://codepen.io&quot;&amp;gt;CodePen&amp;lt;/a&amp;gt;.&amp;lt;/span&amp;gt;
&amp;lt;/p&amp;gt;
&amp;lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;/p&gt;
</content:encoded></item><item><title>关于 UWP 和系统代理</title><link>https://blog.erio.work/posts/%E5%85%B3%E4%BA%8Euwp%E5%92%8C%E7%B3%BB%E7%BB%9F%E4%BB%A3%E7%90%86/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%85%B3%E4%BA%8Euwp%E5%92%8C%E7%B3%BB%E7%BB%9F%E4%BB%A3%E7%90%86/</guid><description>UWP 正常使用系统代理和访问 localhost 的方法</description><pubDate>Tue, 15 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;为什么不能用&lt;/h1&gt;
&lt;p&gt;UWP 应用无法走系统代理实际上是个历史传承问题，在 Win8 的时候微软加入了一个机制叫网络隔离（network isolation），问就是为了安全，这个机制让应用非但不能走系统代理，还不能获取 localhost 上的服务。&lt;/p&gt;
&lt;p&gt;所以偶尔会看到群友说，诶？我用了“相关软件”，怎么我的 Edge 还是不能访问一些网站啊？那是因为 Edge 也是 UWP 应用，也得遵守这项机制。&lt;/p&gt;
&lt;h1&gt;解决方案&lt;/h1&gt;
&lt;p&gt;有两个解决办法，第一个很简单，在你设备之上的网关那启用“相关软件”，这样你设备上不需要变动，发出的数据包自然会走到他该走的地方。&lt;/p&gt;
&lt;p&gt;第二个稍微麻烦一点，先 &lt;code&gt;Win + R&lt;/code&gt; 打开 &lt;code&gt;regedit&lt;/code&gt; 注册表编辑器，然后进入 &lt;code&gt;HKEY_CURRENT_USER\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppContainer\Mappings&lt;/code&gt; 项，一项一项翻。&lt;/p&gt;
&lt;p&gt;找到 Edge（或者其他要“修复”的 UWP 应用），把“目录名称”拷贝下来，如：&lt;code&gt;S-1-15-2-1310292540-1029022339-4008023048-2190398717-53961996-4257829345-603366646&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;然后用管理员权限打开 CMD，输入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CheckNetIsolation.exe loopbackexempt -a -p=&amp;lt;拷贝的内容&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;给指定的应用加入豁免。&lt;/p&gt;
&lt;h1&gt;额外方案&lt;/h1&gt;
&lt;p&gt;那么方法 2 那么麻烦，有没有一键的方式解决呢？那铁是有的，下一个 &lt;strong&gt;&lt;a href=&quot;https://github.com/tiagonmas/Windows-Loopback-Exemption-Manager&quot;&gt;Windows-Loopback-Exemption-Manager&lt;/a&gt;&lt;/strong&gt; 类似的工具，打开，把需要“修复”的应用打上勾就完事了。&lt;/p&gt;
&lt;h1&gt;参考资料&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://techcommunity.microsoft.com/blog/coreinfrastructureandsecurityblog/diagnosing-network-isolation-issues/2511562&quot;&gt;微软社区&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>修复 AirPods 连接 Windows 后的杂音问题</title><link>https://blog.erio.work/posts/%E4%BF%AE%E5%A4%8Dairpods%E8%BF%9E%E6%8E%A5windows%E5%90%8E%E7%9A%84%E6%9D%82%E9%9F%B3%E9%97%AE%E9%A2%98/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E4%BF%AE%E5%A4%8Dairpods%E8%BF%9E%E6%8E%A5windows%E5%90%8E%E7%9A%84%E6%9D%82%E9%9F%B3%E9%97%AE%E9%A2%98/</guid><description>仅能解决问题，但问题的根源未知，可能算是邪道方法</description><pubDate>Mon, 16 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前情提要&lt;/h1&gt;
&lt;p&gt;状况就如标题描述，AirPods 通过蓝牙连接 Windows 后，播放一段时间音频就会出现杂音问题，这和 Windows 有关，但也不是每次都能复现，每次遇到都只能重新连接蓝牙去解决。&lt;/p&gt;
&lt;h1&gt;解决问题&lt;/h1&gt;
&lt;p&gt;我不知道这个问题的原因，但是有一个解决办法，先右键右下角的“喇叭”图标，进入“&lt;strong&gt;声音&lt;/strong&gt;”设置：
&lt;img src=&quot;./20250614145700.png&quot; alt=&quot;图片 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在这里面找到出问题的设备，双击打开“&lt;strong&gt;属性&lt;/strong&gt;”并选择“增强 &lt;strong&gt;Enhancements&lt;/strong&gt;”：
&lt;img src=&quot;./20250614145805.png&quot; alt=&quot;图片 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;将“&lt;strong&gt;Disable all enhancements&lt;/strong&gt;”反选，注意是&lt;strong&gt;反选&lt;/strong&gt;，不是保持到指定的状态。&lt;/p&gt;
&lt;p&gt;因为这里不同设备设置都不一样，有的是勾选了出现杂音，有的相反，所以只能反选了，但不管怎样调整之后杂音问题都能解决，很神秘。&lt;/p&gt;
</content:encoded></item><item><title>记一场酣畅淋漓的前端解密</title><link>https://blog.erio.work/posts/%E8%AE%B0%E4%B8%80%E5%9C%BA%E9%85%A3%E7%95%85%E6%B7%8B%E6%BC%93%E7%9A%84%E5%89%8D%E7%AB%AF%E8%A7%A3%E5%AF%86/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%AE%B0%E4%B8%80%E5%9C%BA%E9%85%A3%E7%95%85%E6%B7%8B%E6%BC%93%E7%9A%84%E5%89%8D%E7%AB%AF%E8%A7%A3%E5%AF%86/</guid><description>逆向解决某漫画网站这几天无法加载章节数的问题</description><pubDate>Fri, 13 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前情提要&lt;/h1&gt;
&lt;p&gt;有点标题党了，不过问题不大，这几天有个著名的漫画网站终于不堪其忧，大改了自己的前端页面，名字不好说，怕被爬虫搜到，反正 6 月 13 号这几天吧，如果你漫画阅读器用不了了，章节数显示不出来了，那么我们的漫画源就是一样的。&lt;/p&gt;
&lt;p&gt;他新的反爬手段是用接口获取章节数，这一改动主要为了阻止的漫画阅读器的爬虫，一些比较简单的阅读器，他就只会加载静态页面，用 selector 去查找元素显示，他无法处理接口请求（至少在规则上无法描述）。&lt;/p&gt;
&lt;h1&gt;过程&lt;/h1&gt;
&lt;p&gt;我一开始没注意，就 F12 在那试，发现所有的 selector 写法都没效果，我还在想是不是阅读器用的移动端 UA 访问，后来突然意识到是接口。&lt;/p&gt;
&lt;p&gt;他接口是这样的：
&lt;img src=&quot;./20250613183058.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;results&lt;/code&gt; 很明显已经加密了，好在是前端，可以在页面中捣鼓，反正一定包含了所需的密钥。&lt;/p&gt;
&lt;p&gt;第一步先猜测，要敢于去猜，然后再找证据尝试推翻猜测，推不翻就没问题。&lt;/p&gt;
&lt;p&gt;我猜他们程序员比较懒狗，变量清理不会太干净，可能把一些信息保存在了全局变量 &lt;code&gt;window&lt;/code&gt; 上，所以我用以下代码检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(function getExtraVars () {
  const keys = []
  const iframe = document.createElement(&apos;iframe&apos;)
  iframe.onload = () =&amp;gt; {
    const iframeKeys = Object.keys(iframe.contentWindow)
    Object.keys(window).forEach(key =&amp;gt; {
      if (!iframeKeys.includes(key)) {
        keys.push(key)
      }
    })
    document.body.removeChild(iframe)
    console.log(keys)
  }
  iframe.src = &apos;about:blank&apos;
  document.body.appendChild(iframe)
})()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;列出了大概 28 个变量：
&lt;img src=&quot;./20250613183430.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;其中有插件注入的，这个不用管，只找“可疑”的，如果分不清可以打印出他们的值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;temp0.map(k =&amp;gt; window[k])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;./20250613184559.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这里可疑的有一个 &lt;code&gt;key&lt;/code&gt;，一个 Base64，于是我猜测密文是 Base64 加密的，但解密出来是乱码，所以这一步不太对。&lt;/p&gt;
&lt;p&gt;那么就应该从 &lt;code&gt;key&lt;/code&gt; 开始，找到 &lt;code&gt;key&lt;/code&gt; 对应的键，叫 &lt;code&gt;dio&lt;/code&gt;，打开调试器，在里面搜索 &lt;code&gt;dio&lt;/code&gt; 这个变量：
&lt;img src=&quot;./20250613185108.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;结果有两个，但这个是函数，所以从这里入手。&lt;/p&gt;
&lt;p&gt;从上方可以看出，这部分应该是对接口结果进行处理，上方先判断响应是否成功，成功就执行 &lt;code&gt;function (_0x55a4d4)&lt;/code&gt;，所以我估计这里的参数 &lt;code&gt;_0x55a4d4&lt;/code&gt; 就是响应结果中的密文 &lt;code&gt;results&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;接着往下走，下面都是混淆后的变量，这里我没什么办法，只能对着 &lt;code&gt;window&lt;/code&gt; 内已暴露出的变量去对照，这里的 &lt;code&gt;_0x4e8a&lt;/code&gt; 是一个&lt;strong&gt;函数&lt;/strong&gt;，暴露在了 &lt;code&gt;window&lt;/code&gt; 上，所以可以尝试去执行，然后慢慢还原代码全貌。（为什么不用断点，因为这是浏览器解析出来的临时代码，不是源码，所以无法打断点调试）&lt;/p&gt;
&lt;p&gt;先从 &lt;code&gt;dio&lt;/code&gt; 所在行入手：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0x3def88 = _0x2e033c[_0x4e8a(&apos;0x59&apos;)][_0x4e8a(&apos;0x8&apos;)][_0x4e8a(&apos;0x5c&apos;)](dio)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里还原之后是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0x3def88 = _0x2e033c[&apos;enc&apos;][&apos;Utf8&apos;][&apos;parse&apos;](dio)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段 API 很眼熟，推测是 CryptoJS 中的，于是我查了一下，应该没错：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./20250613190201.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;所以 &lt;code&gt;_0x2e033c&lt;/code&gt; 的值是 &lt;code&gt;Crypto&lt;/code&gt;，顺带解析其他 &lt;code&gt;_0x4e8a&lt;/code&gt; 的函数、美化变量名，结果变成了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function (encryptedText) {
	var Crypto = /* Crypto 对象*/,
	
	headPart = encryptedText[&apos;substring&apos;](0, 16),
	bodyPart = encryptedText[&apos;substring&apos;](16, encryptedText[&apos;length&apos;]),
	
	dioResult = Crypto[&apos;enc&apos;][&apos;Utf8&apos;][&apos;parse&apos;](dio),
	headResult = Crypto[&apos;enc&apos;][&apos;Utf8&apos;][&apos;parse&apos;](headPart),
	
	_0x2fa5a9 = function (bodyPartStr) {
		var hexResult = Crypto[&apos;enc&apos;][&apos;Hex&apos;][&apos;parse&apos;](bodyPartStr),
		base64Result = Crypto[&apos;enc&apos;][&apos;Base64&apos;][&apos;stringify&apos;](hexResult);
		return Crypto[&apos;AES&apos;][&apos;decrypt&apos;](
			base64Result,
			dioResult,
			{
				&apos;iv&apos;: headResult,
				&apos;mode&apos;: Crypto[&apos;mode&apos;][&apos;CBC&apos;],
				&apos;padding&apos;: Crypto[&apos;pad&apos;][&apos;Pkcs7&apos;]
			}
		) [&apos;toString&apos;](Crypto[&apos;enc&apos;][&apos;Utf8&apos;]) [&apos;toString&apos;]()
	}(bodyPart)
	
	0x14dbee = JSON[_0x4e8a(&apos;0x5c&apos;)](_0x2fa5a9),
	// ...省略
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到了这一步，已经可以确定 &lt;code&gt;_0x2fa5a9&lt;/code&gt; 就是要找的解密函数了，很明显用到的加密就是 AES，只不过 AES 需要的参数都是由 &lt;code&gt;results&lt;/code&gt; 的一些部分计算来的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;头部用于计算出 &lt;code&gt;iv&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;身体包含了章节信息&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;key&lt;/code&gt; 由外部传入，就是挂在 &lt;code&gt;window&lt;/code&gt; 上那个，知道了解密流程，就可以对 &lt;code&gt;results&lt;/code&gt; 进行解析了，丢一张图吧：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./20250613212110.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;总结&lt;/h1&gt;
&lt;p&gt;感觉这次运气成分比较多，正好遇上懒狗程序员把东西挂在 &lt;code&gt;window&lt;/code&gt; 上，让我能从 &lt;code&gt;window&lt;/code&gt; 入手，要不然得认真看源码了。&lt;/p&gt;
&lt;p&gt;调试器中的 &lt;code&gt;SourceXXXX&lt;/code&gt; 从内容上看是页面运行时的代码/变量，关键是&lt;strong&gt;运行时&lt;/strong&gt;，所以打不了断点，或者说打了会重置，这点比较麻烦，不知道正确的调试方式是什么。&lt;/p&gt;
</content:encoded></item><item><title>解决 Tabler 升级导致分块过多的 Bug</title><link>https://blog.erio.work/posts/%E8%A7%A3%E5%86%B3tabler%E5%8D%87%E7%BA%A7%E5%AF%BC%E8%87%B4%E5%88%86%E5%9D%97%E8%BF%87%E5%A4%9A%E7%9A%84bug/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%A7%A3%E5%86%B3tabler%E5%8D%87%E7%BA%A7%E5%AF%BC%E8%87%B4%E5%88%86%E5%9D%97%E8%BF%87%E5%A4%9A%E7%9A%84bug/</guid><description>Tabler 图标 v3.19 出现的拖慢 Vite 开发环境的问题</description><pubDate>Wed, 21 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;前两天对项目的依赖做了一次升级，已经两年没有更过了，这次涉及的依赖是 mantine 和 tabler，但升级之后出现了一个神秘问题，本文用于记录现象、排查过程与解决方案。&lt;/p&gt;
&lt;p&gt;严格来说，这是 tabler 和 vite 的问题。&lt;/p&gt;
&lt;h2&gt;问题现象&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;开发环境初始化加载 6000+ chunks&lt;/li&gt;
&lt;li&gt;本地服务冷启动耗时增加约30秒&lt;/li&gt;
&lt;li&gt;生产构建不受影响&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20250518-015801.png&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;./QQ20250518-014800.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;排查过程&lt;/h2&gt;
&lt;h2&gt;先观察异常请求的特征&lt;/h2&gt;
&lt;p&gt;通过 Network 面板发现大量 &amp;lt;5kb 的 chunk 请求，点开响应可以看到包含 @tabler 关键字。
&lt;img src=&quot;./QQ20250518-015823.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;从依赖上溯源&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;@tabler/icons-react@3.19&lt;/code&gt; 的源码中发现他是通过动态导入的方式加载所有图标的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var dynamicImports = {
  &quot;a-b-2&quot;: () =&amp;gt; import(&apos;./icons/IconAB2.mjs&apos;),
  // ...
}

export { dynamicImports as default };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;import()&lt;/code&gt; 属于动态导入，对于每一个导入的内容，vite 都会创建出一个独立的 chunk，到这里还不能说完全有问题，关键在于他使用 &lt;code&gt;dynamicImports&lt;/code&gt; 的地方：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import * as dynamicImports from &apos;./dynamic-imports.mjs&apos;;

export { dynamicImports };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 tabler 这个依赖的入口文件 &lt;code&gt;tabler-icons-react.mjs&lt;/code&gt; 中，他将 &lt;code&gt;dynamicImports&lt;/code&gt; 给直接导入导出了，就意味着你如果导入了 &lt;code&gt;@tabler/icons&lt;/code&gt;，在开发环境下，vite 分析依赖的时就会把相关的的模块全都加载进来。&lt;/p&gt;
&lt;p&gt;根据 issue 的记录，这个写法是为了支持动态加载图标而加入的，就是 &lt;code&gt;&amp;lt;Icon name={iconName} /&amp;gt;&lt;/code&gt; 这种场景，但 pr 提供者似乎完全没有考虑到使用 vite 构建工具的情况。&lt;/p&gt;
&lt;h1&gt;解决方案&lt;/h1&gt;
&lt;p&gt;我这里想到、收集到的总共三种：&lt;/p&gt;
&lt;h2&gt;版本回退&lt;/h2&gt;
&lt;p&gt;回退到 3.17 版本，这样可能会丢失一些新版本加入的图标&lt;/p&gt;
&lt;h2&gt;代码修补&lt;/h2&gt;
&lt;p&gt;通过 &lt;code&gt;patch-package&lt;/code&gt; 修改入口文件，把那两行注释掉，失去动态导入的特性。&lt;/p&gt;
&lt;h2&gt;别名&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;// vite.config.js
export default {
  resolve: {
    alias: {
      &apos;@tabler/icons-react&apos;: &apos;node_modules/@tabler/icons-react/dist/esm/icons/index.mjs&apos;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;跳过依赖的指定入口，直接使用 esm 构建好的静态模块，但这种也要注意一个问题，有些静态文件的名称和导出时的名字不一样，比如 &lt;code&gt;IconBoxSeam&lt;/code&gt; 是找不到对应图标的，因为图标文件名是 &lt;code&gt;Package&lt;/code&gt;。&lt;/p&gt;
</content:encoded></item><item><title>解决耳机在进入游戏后音质降低的问题</title><link>https://blog.erio.work/posts/%E8%A7%A3%E5%86%B3%E8%80%B3%E6%9C%BA%E5%9C%A8%E8%BF%9B%E5%85%A5%E6%B8%B8%E6%88%8F%E5%90%8E%E9%9F%B3%E8%B4%A8%E9%99%8D%E4%BD%8E%E7%9A%84%E9%97%AE%E9%A2%98/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%A7%A3%E5%86%B3%E8%80%B3%E6%9C%BA%E5%9C%A8%E8%BF%9B%E5%85%A5%E6%B8%B8%E6%88%8F%E5%90%8E%E9%9F%B3%E8%B4%A8%E9%99%8D%E4%BD%8E%E7%9A%84%E9%97%AE%E9%A2%98/</guid><pubDate>Thu, 08 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;曾经有个问题困扰了我很久，在戴着一些耳机进入游戏后，耳机声音会变小，音质会降低，会出现这种问题的&lt;strong&gt;往往是无线耳机，而且这种耳机都有带有麦克风&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;原因在无线耳机走的是&lt;strong&gt;蓝牙通信&lt;/strong&gt;，但蓝牙因为技术原因，&lt;strong&gt;没法支持音频输入（讲话）和立体声同时运作&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;li&gt;插上一个&lt;strong&gt;外置麦克风&lt;/strong&gt;，将他设置为&lt;strong&gt;默认音频输入&lt;/strong&gt;，这样耳机就不会被迫降级&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果你不是一个喜欢开麦的玩家，方法一就能用于绝大多数游戏，为什么说是绝大部分呢？因为还有&lt;strong&gt;一些游戏他不守规矩&lt;/strong&gt;，比如 HellDivers 2，他不管你怎么设置，启动的时候就默认会调用系统麦克风，即便你压根就没开“语音聊天”，他也会这么做。&lt;/p&gt;
&lt;p&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;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;p&gt;&lt;img src=&quot;20250507160546.png&quot; alt=&quot;1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;20250507160617.png&quot; alt=&quot;2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;20250507160738.png&quot; alt=&quot;3&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>关于图片搜索，我想说的</title><link>https://blog.erio.work/posts/%E5%85%B3%E4%BA%8E%E5%9B%BE%E7%89%87%E6%90%9C%E7%B4%A2%E6%88%91%E6%83%B3%E8%AF%B4%E7%9A%84/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%85%B3%E4%BA%8E%E5%9B%BE%E7%89%87%E6%90%9C%E7%B4%A2%E6%88%91%E6%83%B3%E8%AF%B4%E7%9A%84/</guid><description>图片搜索的原理和图片选择的建议</description><pubDate>Mon, 28 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;找图的方法与区别&lt;/h1&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;strong&gt;这种方法是最好的&lt;/strong&gt;，而且找起来很快，认识的人一眼就能看出来，但关键就是这个前提 - “&lt;strong&gt;人足够多&lt;/strong&gt;”，没有前提就没法往下进行。&lt;/p&gt;
&lt;p&gt;机找包括了各种工具和搜索引擎的查找，不管你是用什么，都是在&lt;strong&gt;庞大图片数据库的加持&lt;/strong&gt;下实现的，什么强力的搜图算法都得要更强大的数据库，因为算法只是用来&lt;strong&gt;比较图片是否相似&lt;/strong&gt;的，拿什么比较？足够多的图片啊！&lt;/p&gt;
&lt;p&gt;所以&lt;strong&gt;对于不在数据库内的图片，搜索工具也没什么办法&lt;/strong&gt;，毕竟工具又不可能给你临摹一张（或许未来能用 AI 实现）。&lt;/p&gt;
&lt;h1&gt;图片数据库从哪来&lt;/h1&gt;
&lt;p&gt;那么图片数据库是怎么来的呢？也是存在几种方式，第一种就是“我”自己就是做图片相关产业的，比如 Pixiv，他&lt;strong&gt;自己是一个图片源头，保存了很多图片&lt;/strong&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;，因为他本身就是一个图片站点，大家都用它发布图片。&lt;/p&gt;
&lt;p&gt;而方法二是一个收集者，图 A 图 B 的页面有关联，那么他能收集到，如果图 C 页面“孤立无援”，他是收集不到的，所以&lt;strong&gt;搜索引擎等工具他能找到的图片有限&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;要注意，每一个搜索引擎收录的网站都不一样，所以他们的数据库也不一样，甲没有的图，可能乙那有。&lt;/p&gt;
&lt;h1&gt;关于动图&lt;/h1&gt;
&lt;p&gt;对于动图，目前没有什么好的搜索方法，都是将动图转换为静态图进行的，因为对于算法来说，搜索动图等同于搜索很多张静态图，成本一下就拉高了。&lt;/p&gt;
&lt;p&gt;所以动图、视频还是需要截取其中的一些画面去搜索，需要反复尝试，而且成功率没有静态图高，之前也说了，搜图是通过图片比对实现的，&lt;strong&gt;你截图出来的那一秒，可能别人没有截到过，或者截到了没有发上网，进不了数据库，自然就搜索不到&lt;/strong&gt;。&lt;/p&gt;
&lt;h1&gt;选择图片的讲究&lt;/h1&gt;
&lt;p&gt;基于以上几点，搜图提供的图片是有讲究的：&lt;/p&gt;
&lt;ol&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;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;一些心得&lt;/h1&gt;
&lt;p&gt;搜图不是一蹴而就的，原图搜索不到的情况很常见，这时候就需要你有好的眼力和推理水平，通过搜索结果判断和原图是否有关系（比如背景装饰、画风、衣物等），有什么关系，从而推导出来源。&lt;/p&gt;
</content:encoded></item><item><title>关于韩文输入的原理</title><link>https://blog.erio.work/posts/%E5%85%B3%E4%BA%8E%E9%9F%A9%E6%96%87%E8%BE%93%E5%85%A5%E7%9A%84%E5%8E%9F%E7%90%86/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%85%B3%E4%BA%8E%E9%9F%A9%E6%96%87%E8%BE%93%E5%85%A5%E7%9A%84%E5%8E%9F%E7%90%86/</guid><description>韩文文字的拆解以及如何通过各声部的组合出文字</description><pubDate>Mon, 28 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;因为冒险岛的更新改动总是从韩服开始进行，所以我们不得不盯着韩服的动态，一些图像识别不出来的文字我们会尝试打出来，再拿去翻译。&lt;/p&gt;
&lt;p&gt;在这个过程中，我用的都是在线的韩语输入法，在使用的时候我注意到，韩语文字都是“拼”出来的，比如“셩”，是由“ㅅ”、“ㅕ”和“ㅇ”这三个部分组成的。&lt;/p&gt;
&lt;p&gt;经过查阅发现，韩文还真是拼出来的，逻辑相当简单，韩文把三个部分分别命名为“初声”（19 个）、“中声”（21 个）和“终声”（28 个）。&lt;/p&gt;
&lt;p&gt;所以理论上可以拼出 &lt;code&gt;19 * 21 * 28 = 11172&lt;/code&gt; 个字，注意是理论上，因为实际在输入法中打不出那么多，毕竟键盘上的键位也有限。&lt;/p&gt;
&lt;p&gt;虽然打不出来，但 Unicode 中是包含了所有的韩文文字的，从 &lt;code&gt;U+AC00&lt;/code&gt; 开始，往下数 11171 个，他们的排列方式是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;初1中1终1、初1中1终2...初1中1终28、初1中2终1......初19中21终28
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于开发者，看到这种排列其实就心里有数了，这就是三维数组的遍历，把三个部分分别看做 x、y、z 的话，那么知道 xyz，计算一个文字 Unicode 的方法就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const unicode = 0xAC00 + (x * 21 + y) * 28 + z
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同理，知道一个文字，计算各部件的组合也是可行的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function toIndex(char: string) {
	const charCode = char.charCodeAt(0)
	if (isNaN(charCode) || charCode &amp;lt; 0xAC00 || charCode &amp;gt; 0xD7A3) {
		throw new Error(&apos;...&apos;)
	}
	const code = charCode - 0xAC00
	const z = code % 28;
	const temp = Math.floor(code / 28);
	const y = code % 21;
	const x = Math.floor(code / 21);
	return { x, y, z };
}
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>关于 vw、vh 在移动端行为不一致的问题</title><link>https://blog.erio.work/posts/%E5%85%B3%E4%BA%8Evwvh%E5%9C%A8%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%A1%8C%E4%B8%BA%E4%B8%8D%E4%B8%80%E8%87%B4%E7%9A%84%E9%97%AE%E9%A2%98/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%85%B3%E4%BA%8Evwvh%E5%9C%A8%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%A1%8C%E4%B8%BA%E4%B8%8D%E4%B8%80%E8%87%B4%E7%9A%84%E9%97%AE%E9%A2%98/</guid><description>在安卓上，vh 所表示的并不一定是用户的可见区域，很可能会超出范围</description><pubDate>Fri, 25 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;起因与情况&lt;/h1&gt;
&lt;p&gt;昨天遇到了一个 Bug，有小伙伴反馈页面的应用窗口在手机上看不全，我自己拿 iPhone 16 试了一下，没复现，我就以为是一些杂牌浏览器的问题，以前也见过类似的，比如浏览器下方工具栏弹出的时候会遮挡页面。&lt;/p&gt;
&lt;p&gt;在和他进行讨论后，发现他不管什么浏览器都会出现这种问题，包括换一台手机也一样，换的也是安卓，我也和他一起换，但我自己手机上还是没问题，后来我找了一台安卓机过来，复现了。&lt;/p&gt;
&lt;p&gt;我发两张对比图：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./IMG_4117.png&quot; alt=&quot;alt text&quot; /&gt;
&lt;img src=&quot;./Screenshot_20250424_182753_Edge.jpg&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;白色主题的是 iOS 上的浏览器，黑色主题的是安卓上的浏览器，同样的代码，在不同设备上的效果不一样。&lt;/p&gt;
&lt;h1&gt;复现和分析&lt;/h1&gt;
&lt;p&gt;Demo 代码如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
  &amp;lt;title&amp;gt;Document&amp;lt;/title&amp;gt;
  &amp;lt;style&amp;gt;
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      overflow: hidden;
    }

    .container {
      position: absolute;
      width: 100vw;
      height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      background: rgba(0, 0, 0, 0.3);
    }

    .card {
      position: relative;
      height: 90%;
      width: 80%;
      background: white;
    }

    .footer {
      position: fixed;
      left: 50%;
      transform: translateX(-50%);
      bottom: 8px;
    }
  &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;div class=&quot;container&quot;&amp;gt;
    &amp;lt;div class=&quot;card&quot;&amp;gt;
      卡片
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div class=&quot;footer&quot;&amp;gt;123123&amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;简述一下最小的复现场景，外层容器设置了position 和 100vw、100vh 的尺寸，内容高度是百分比，也就像相对于容器的。&lt;/p&gt;
&lt;p&gt;按理说容器全屏，内容高度百分比，是绝对不会超出 viewport 的大小的，即便是浏览器上下工具栏对 viewport 有挤压，也只是会影响 viewport 的高度。&lt;/p&gt;
&lt;p&gt;为了避免工具栏漂浮，覆盖了部分页面，我加了一个 footer 作为 fixed 固定在浏览器窗口下方，可以看到 123123 是没有被遮盖的，也就是说此时&lt;strong&gt;浏览器窗口并没有被下方工具栏漂浮遮挡&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;所以我这里就觉得是 container 的问题了，后面排除了一下，确定问题出在 vw、vh 单位上，查了一下原因，也和读者分享一下。&lt;/p&gt;
&lt;p&gt;vw、vh 单位，确实能表示 viewport 的尺寸，但是问题出在他们是静态的单位，他们在确定了具体的尺寸（px）之后就不会改变了，什么情况下会造成改变呢？比如工具栏，如果出现了工具栏，那么很明显用户能看到的部分变小了，但是 vh 并不会改变，可视区域小了，但尺寸不变，那肯定有一部分不可见了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;1_6AOTC64DUruIzIMdAj1JRw.webp&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这一张来自 StackOverflow 的图就能很好的说明问题，viewport 高度始终是 100vh，但用户能看到的部分不等于 viewport。&lt;/p&gt;
&lt;p&gt;还记得开头说的吗，iOS 上无法复现，对的，iOS 的 WebKit 和 安卓的 WebView 实现不一样，iOS 上用的是动态 vw、vh，也就是 viewport 的实际尺寸会根据可见区域进行调整，出现工具栏了，那么 100vh 对应的 px 就会减少，用户看到的可视区域就是 viewport。&lt;/p&gt;
&lt;h1&gt;解决&lt;/h1&gt;
&lt;p&gt;知道了原因，解决方案就出来了，有几种。&lt;/p&gt;
&lt;p&gt;第一是自己维护一个变量作为 vw、vh 对应的像素数，通过 window 的 innerWidth 和 innerHeight 计算，再加上一个 resize 事件重算。&lt;/p&gt;
&lt;p&gt;第二是用新单位 dvw 和 dvh，表示动态的 viewport 尺寸，22 年实装的，但肯定有不少用户懒得更新浏览器。&lt;/p&gt;
&lt;p&gt;第三是避免这个单位，比如刚刚 Demo，完全可以用百分比来确定容器的尺寸。&lt;/p&gt;
</content:encoded></item><item><title>关于 Blob url 在火狐和 Chromium 上的行为不一致</title><link>https://blog.erio.work/posts/%E5%85%B3%E4%BA%8Ebloburl%E5%9C%A8%E7%81%AB%E7%8B%90%E5%92%8Cchromium%E4%B8%8A%E7%9A%84%E8%A1%8C%E4%B8%BA%E4%B8%8D%E4%B8%80%E8%87%B4/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%85%B3%E4%BA%8Ebloburl%E5%9C%A8%E7%81%AB%E7%8B%90%E5%92%8Cchromium%E4%B8%8A%E7%9A%84%E8%A1%8C%E4%B8%BA%E4%B8%8D%E4%B8%80%E8%87%B4/</guid><description>经过 revoke 的 blob 在 img 元素上依然能够正常显示</description><pubDate>Tue, 15 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;概括&lt;/h1&gt;
&lt;p&gt;对于 revoked 的 blob url，火狐依然能通过元素访问，但 Chromium 不行。&lt;/p&gt;
&lt;h1&gt;起因&lt;/h1&gt;
&lt;p&gt;起因是在做一个功能的时候，火狐正常，但 Chromium 全系报错，具体代码是这样的（代码逻辑有误，但阴差阳错发现了这个问题）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;useEffect(() =&amp;gt; {
    return () =&amp;gt; {
      if (imageA) URL.revokeObjectURL(imageA);
      if (imageB) URL.revokeObjectURL(imageB);
      if (mergedImage) URL.revokeObjectURL(mergedImage);
    };
  }, [imageA, imageB, mergedImage]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;功能是两张图片合并，这段代码本意是个清理函数，但当时脑子不清醒将他们写在了一起，这样就出现了一个逻辑问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用户先选择图 A，正常&lt;/li&gt;
&lt;li&gt;用户选择了图 B，此时执行上一轮的清理函数，将图 A 的 Blob url 回收了&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在图片合并时会使用图 A 和图 B 的 url，此时在 Chromium 系浏览器就报错了，提示 &lt;code&gt;ERR_FILE_NOT_FOUND&lt;/code&gt;，但火狐上依然正常。&lt;/p&gt;
&lt;h1&gt;复现&lt;/h1&gt;
&lt;p&gt;针对这个问题，我做了一个小 Demo 去测试：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
  &amp;lt;title&amp;gt;Test revoke&amp;lt;/title&amp;gt;
  &amp;lt;style&amp;gt;
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      overflow: hidden;
    }
  &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;input type=&quot;file&quot; id=&quot;file&quot;&amp;gt;upload&amp;lt;/button&amp;gt;
  &amp;lt;button&amp;gt;revoke&amp;lt;/button&amp;gt;
  &amp;lt;button&amp;gt;remove all&amp;lt;/button&amp;gt;
  &amp;lt;script&amp;gt;
    const fileInput = document.getElementById(&apos;file&apos;);

    const createBlobUrl = (file) =&amp;gt; {
      const blobUrl = URL.createObjectURL(file);
      console.log(blobUrl);
      return blobUrl;
    };

    const revokeBlobUrl = (blobUrl) =&amp;gt; {
      URL.revokeObjectURL(blobUrl);
      console.log(&apos;revoke blob url:&apos;, blobUrl);
    };

		let blobUrl = null;
    fileInput.addEventListener(&apos;change&apos;, (e) =&amp;gt; {
      const file = e.target.files[0];
      if (file) {
        blobUrl = createBlobUrl(file);
        const img = document.createElement(&apos;img&apos;);
        img.src = blobUrl;
        img.style.width = &apos;300px&apos;;
        img.style.height = &apos;300px&apos;;
        document.body.appendChild(img);
      }
    });

    document.querySelector(&apos;button&apos;).addEventListener(&apos;click&apos;, () =&amp;gt; {
      revokeBlobUrl(blobUrl);
      const img = document.createElement(&apos;img&apos;);
      img.src = blobUrl;
      img.style.width = &apos;300px&apos;;
      img.style.height = &apos;300px&apos;;
      document.body.appendChild(img);

      setTimeout(() =&amp;gt; {
        const img2 = document.createElement(&apos;img&apos;);
        img2.src = blobUrl;
        img2.style.width = &apos;300px&apos;;
        img2.style.height = &apos;300px&apos;;
        document.body.appendChild(img2);
      }, 5000);
    });

    // remove all img elements
    document.querySelector(&apos;button:nth-of-type(2)&apos;).addEventListener(&apos;click&apos;, () =&amp;gt; {
      const imgs = document.querySelectorAll(&apos;img&apos;);
      imgs.forEach(img =&amp;gt; {
        img.remove();
      });

      const img = document.createElement(&apos;img&apos;);
      img.src = blobUrl;
      img.style.width = &apos;300px&apos;;
      img.style.height = &apos;300px&apos;;
      document.body.appendChild(img);
    });
  &amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用流程是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;使用 input 元素选择一张图片进行显示&lt;/li&gt;
&lt;li&gt;点击 revoke 按钮，查看新的两张图片是否能显示&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在火狐上，三张图片都是能够正常显示的，但在 Chromium 系浏览器上，只有第一张图片能正常显示，后面两张图片会显示为 ERR_FILE_NOT_FOUND。&lt;/p&gt;
&lt;p&gt;但使用 fetch 访问 blob url 时，两种浏览器都能正常抛出错误，所以我认为是火狐浏览器出现了问题，是内核 Gecko 的。&lt;/p&gt;
&lt;p&gt;目前已提交到 Bugzilla，等待回复。&lt;/p&gt;
&lt;h2&gt;更新&lt;/h2&gt;
&lt;p&gt;官方已确定是 Bug，已排上修复日程。&lt;/p&gt;
</content:encoded></item><item><title>实现 Tumbex 的瀑布流效果</title><link>https://blog.erio.work/posts/%E5%AE%9E%E7%8E%B0tumbex%E7%9A%84%E7%80%91%E5%B8%83%E6%B5%81%E6%95%88%E6%9E%9C/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%AE%9E%E7%8E%B0tumbex%E7%9A%84%E7%80%91%E5%B8%83%E6%B5%81%E6%95%88%E6%9E%9C/</guid><description>拆解 Tumbex 的瀑布流布局，并使用 JS 进行复刻</description><pubDate>Fri, 11 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Tumbex 的效果（NSFW）：&lt;a href=&quot;https://www.tumbex.com/morgansq480x480.tumblr/posts&quot;&gt;https://www.tumbex.com/morgansq480x480.tumblr/posts&lt;/a&gt;&lt;/p&gt;
&lt;h1&gt;拆解&lt;/h1&gt;
&lt;p&gt;特性可以总结成以下几点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;项目分成 N 列，加载时会往最短的那列添加项目&lt;/li&gt;
&lt;li&gt;支持 responsive，通过 breakpoint 去控制大小屏显示的列数&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;p&gt;通过 F12 检查，可以发现他是通过 &lt;code&gt;transform&lt;/code&gt; 实现的，而不是 &lt;code&gt;grid&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;简单来说，每个项目都是 &lt;code&gt;absolute&lt;/code&gt; 定位的，通过 &lt;code&gt;translate&lt;/code&gt; 去计算自己的位置，去确定新的项目应该加到哪一列上。&lt;/p&gt;
&lt;p&gt;responsive 是通过媒体查询 &lt;code&gt;@media&lt;/code&gt; 和百分比宽度实现的，最小显示一列，最大显示九列，再大的屏幕上项目宽度也只会是 11.11111%，此时对于过宽的项目，其中的图片会横向显示成多张（内容的显示是另一个主题了）。&lt;/p&gt;
&lt;h1&gt;实现&lt;/h1&gt;
&lt;p&gt;基于以上的分析和拆解，可以写出一套 &lt;code&gt;waterfall&lt;/code&gt; 类：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface Breakpoint {
  minWidth: number;
  columns: number;
}

class Waterfall {
  private container: HTMLElement;
  private breakpoints: Breakpoint[];
  private gap: number;
  private currentColumns!: number;
  private columnWidth!: number;
  private columnHeights: number[] = [];
  private resizeObserver: ResizeObserver;

  constructor(container: HTMLElement, breakpoints: Breakpoint[] = [], gap: number = 10) {
    this.container = container;
    this.breakpoints = this.normalizeBreakpoints(breakpoints);
    this.gap = gap;
    this.resizeObserver = new ResizeObserver(this.handleResize.bind(this));
    this.init();
  }

  private normalizeBreakpoints(breakpoints: Breakpoint[]): Breakpoint[] {
    // 默认断点
    const defaultBreakpoints = [
      { minWidth: 0, columns: 1 },
      { minWidth: 768, columns: 2 },
      { minWidth: 1024, columns: 3 }
    ];
    
    return [...defaultBreakpoints, ...breakpoints]
      .sort((a, b) =&amp;gt; a.minWidth - b.minWidth)
      .filter((v, i, arr) =&amp;gt; 
        arr.findIndex(t =&amp;gt; t.minWidth === v.minWidth) === i
      );
  }

  private init(): void {
    this.container.style.position = &apos;relative&apos;;
    this.resizeObserver.observe(this.container);
    this.updateColumns();
    this.calculateLayout();
  }

  // 处理窗口尺寸变化
  private handleResize(entries: ResizeObserverEntry[]): void {
    entries.forEach(entry =&amp;gt; {
      if (entry.contentRect.width !== this.container.offsetWidth) {
        this.updateColumns();
        this.calculateLayout();
      }
    });
  }

  // 更新列数
  private updateColumns(): void {
    const containerWidth = this.container.offsetWidth;
    const validBreakpoints = this.breakpoints.filter(bp =&amp;gt; bp.minWidth &amp;lt;= containerWidth);
    const newColumns = validBreakpoints.reduce((prev, curr) =&amp;gt; 
      curr.minWidth &amp;gt; prev.minWidth ? curr : prev
    ).columns;

    if (newColumns !== this.currentColumns) {
      this.currentColumns = newColumns;
      this.columnHeights = new Array(this.currentColumns).fill(0);
    }
  }
  
  private calculateLayout(): void {
    this.calculateColumnWidth();
    this.reflowAllItems();
  }

  private calculateColumnWidth(): void {
    const containerWidth = this.container.offsetWidth;
    this.columnWidth = (containerWidth - (this.currentColumns - 1) * this.gap) / this.currentColumns;
  }

  public addItem(item: HTMLElement): void {
    item.style.position = &apos;absolute&apos;;
    item.style.width = `${this.columnWidth}px`;

    const images = item.getElementsByTagName(&apos;img&apos;);
    if (images.length &amp;gt; 0) {
      this.waitForImages(item, images).then(() =&amp;gt; this.placeItem(item));
    } else {
      this.placeItem(item);
    }
  }

  // 等待图片加载
  private async waitForImages(item: HTMLElement, images: HTMLCollectionOf&amp;lt;HTMLImageElement&amp;gt;): Promise&amp;lt;void&amp;gt; {
    return new Promise(resolve =&amp;gt; {
      let loaded = 0;
      const checkLoad = () =&amp;gt; ++loaded === images.length &amp;amp;&amp;amp; resolve();

      Array.from(images).forEach(img =&amp;gt; {
        if (img.complete) checkLoad();
        else img.addEventListener(&apos;load&apos;, checkLoad);
      });
    });
  }

  private placeItem(item: HTMLElement): void {
    const minHeight = Math.min(...this.columnHeights);
    const columnIndex = this.columnHeights.indexOf(minHeight);
    const top = minHeight + (minHeight === 0 ? 0 : this.gap);
    const left = columnIndex * (this.columnWidth + this.gap);

    item.style.transform = `translate3d(${left}px, ${top}px, 0)`;
    this.container.appendChild(item);
    
    this.columnHeights[columnIndex] = top + item.offsetHeight;
    this.updateContainerHeight();
  }

  private reflowAllItems(): void {
    this.columnHeights = new Array(this.currentColumns).fill(0);
    const items = Array.from(this.container.children) as HTMLElement[];
    
    items.forEach(item =&amp;gt; {
      item.style.width = `${this.columnWidth}px`;
      const itemHeight = item.offsetHeight;
      
      const minHeight = Math.min(...this.columnHeights);
      const columnIndex = this.columnHeights.indexOf(minHeight);
      const top = minHeight + (minHeight === 0 ? 0 : this.gap);
      const left = columnIndex * (this.columnWidth + this.gap);

      item.style.transform = `translate3d(${left}px, ${top}px, 0)`;
      this.columnHeights[columnIndex] = top + itemHeight;
    });
    
    this.updateContainerHeight();
  }

  private updateContainerHeight(): void {
    const maxHeight = Math.max(...this.columnHeights);
    this.container.style.minHeight = `${maxHeight}px`;
  }

  public destroy(): void {
    this.resizeObserver.unobserve(this.container);
  }
}

// 使用示例
const container = document.getElementById(&apos;waterfall-container&apos;) as HTMLElement;

// 自定义断点配置（可选）
const customBreakpoints = [
  { minWidth: 0, columns: 1 },    // &amp;lt; 600px: 1列
  { minWidth: 600, columns: 2 },  // ≥600px: 2列
  { minWidth: 900, columns: 3 },  // ≥900px: 3列
  { minWidth: 1200, columns: 4 }  // ≥1200px: 4列
];

const waterfall = new Waterfall(container, customBreakpoints, 15);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说一下变量和流程吧。&lt;/p&gt;
&lt;p&gt;变量：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;container&lt;/code&gt;：瀑布流容器&lt;/li&gt;
&lt;li&gt;&lt;code&gt;breakpoints&lt;/code&gt;：尺寸断点，控制在不同断点下显示多少列&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gap&lt;/code&gt;：项目之间的间隔&lt;/li&gt;
&lt;li&gt;&lt;code&gt;currentColumns&lt;/code&gt;：当前显示的列数&lt;/li&gt;
&lt;li&gt;&lt;code&gt;columnWidth&lt;/code&gt;：单列的宽度&lt;/li&gt;
&lt;li&gt;&lt;code&gt;columnHeights&lt;/code&gt;：每一列的高度数组&lt;/li&gt;
&lt;li&gt;&lt;code&gt;resizeObserver&lt;/code&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;code&gt;breakpoint&lt;/code&gt; 的确认，确定列数，然后进行摆放&lt;/li&gt;
&lt;li&gt;之后会注册一个 &lt;code&gt;resizeObserver&lt;/code&gt; 去监听容器的变化，因为容器不一定是 &lt;code&gt;body&lt;/code&gt;，所以不能简单用 &lt;code&gt;resize&lt;/code&gt; 事件，变化时调用 &lt;code&gt;handleResize&lt;/code&gt;（实际上就是回到步骤 2）
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;handleResize&lt;/code&gt; 会先根据新尺寸找到断点，更新总列数，把 &lt;code&gt;columnHeights&lt;/code&gt; 清零，之后通过 &lt;code&gt;reflowAllItems&lt;/code&gt; 重新摆放项目&lt;/li&gt;
&lt;li&gt;所以这一步在整体中开销比较大，可以稍微加上 &lt;code&gt;debounce&lt;/code&gt; 优化，但由于用的是 &lt;code&gt;transform&lt;/code&gt; 并不会反复进行 layout，属于是&lt;strong&gt;开销大但又没那么大&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;摆放完成后通过 &lt;code&gt;updateContainerHeight&lt;/code&gt; 重新计算容器的总高度&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addItem&lt;/code&gt; 是对外暴露的添加项目的方法，他会负责找到项目内的 &lt;code&gt;img&lt;/code&gt; 并等待图片加载，因为摆放需要得到项目的高度，所以不得不等待一切异步的内容&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;效果预览&lt;/h1&gt;
&lt;p&gt;&amp;lt;iframe height=&quot;600&quot; style=&quot;width: 100%;&quot; scrolling=&quot;no&quot; title=&quot;Untitled&quot; src=&quot;https://codepen.io/erioifpud/embed/VYwJJya?default-tab=result&quot; frameborder=&quot;no&quot; loading=&quot;lazy&quot; allowtransparency=&quot;true&quot; allowfullscreen=&quot;true&quot;&amp;gt;
See the Pen &amp;lt;a href=&quot;https://codepen.io/erioifpud/pen/VYwJJya&quot;&amp;gt;
Untitled&amp;lt;/a&amp;gt; by Erioifpud (&amp;lt;a href=&quot;https://codepen.io/erioifpud&quot;&amp;gt;@erioifpud&amp;lt;/a&amp;gt;)
on &amp;lt;a href=&quot;https://codepen.io&quot;&amp;gt;CodePen&amp;lt;/a&amp;gt;.
&amp;lt;/iframe&amp;gt;&lt;/p&gt;
</content:encoded></item><item><title>关于图片隐写的研究</title><link>https://blog.erio.work/posts/%E5%85%B3%E4%BA%8E%E5%9B%BE%E7%89%87%E9%9A%90%E5%86%99%E7%9A%84%E7%A0%94%E7%A9%B6/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%85%B3%E4%BA%8E%E5%9B%BE%E7%89%87%E9%9A%90%E5%86%99%E7%9A%84%E7%A0%94%E7%A9%B6/</guid><description>把两张图片合并写入到一张图片中，并且以背景色作为图片切换显示的开关</description><pubDate>Fri, 04 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前言&lt;/h1&gt;
&lt;p&gt;起因是前几天在搜图那看到了一张比较“特别”的图：
&lt;img src=&quot;./103b5f9ff31d08d8aeb43b569fd9c486.webp&quot; alt=&quot;图片 1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这张图本身带有一些透明度，在白色/黑色（或者说浅色/深色）背景下能显示两种图案，也就是把两张图以某种方式合并在了一起。&lt;/p&gt;
&lt;p&gt;关于这种方式我想了几天，最后得出了一种方案，可能和他的不太一样，但也算能实现。&lt;/p&gt;
&lt;h1&gt;原理&lt;/h1&gt;
&lt;p&gt;首先得知道半透明的像素点，放在背景上，最终会渲染成什么颜色：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;渲染的颜色 = 点的颜色 * 透明度 + 背景的颜色 * (1 - 透明度)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我的方法主要是将两张图片的像素点间隔排列，因为看上面的“渲染方式”，在一个像素点内，控制显示成两种颜色非常困难，所以干脆&lt;strong&gt;分离&lt;/strong&gt;成两个点，让其中一个在黑/白背景下不可见，以凸显另一个的颜色。&lt;/p&gt;
&lt;p&gt;这种做法不会出现明显的间隔，因为“缺失”的点在渲染的时候会被背景色补全。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./Checkerboard_pattern.svg.png&quot; alt=&quot;图片 2&quot; /&gt;
假设每个格子都是半透明的，在黑色背景下，本来纯黑的部分就会和背景融为一体，白色的部分会叠加成灰色，反之同理，在白色背景下，白色格子不可见，原本的黑色格子的“黑度”会被稀释成灰色。&lt;/p&gt;
&lt;p&gt;所以解题的关键就成了“灰度”，&lt;strong&gt;在颜色叠加之后，利用灰度来区分像素点的颜色变化&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;流程上就变成了，先拿到图片像素点的灰度值，然后根据灰度值算出对应的透明度，在白色背景下让图 A 的每个点变成“不同透明度的黑色”，反之在黑色背景下让图 B 的每个点变成“不同透明度的白色”，再把两张图片间隔叠加实现合并。&lt;/p&gt;
&lt;p&gt;至于灰度的计算，我查了一下，在不同的色彩空间下，计算方式不一样，我使用的是&lt;a href=&quot;https://www.zhihu.com/question/22039410/answer/24023185&quot;&gt;这个回答&lt;/a&gt;提到的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Y&apos; = 0.299 R&apos; + 0.587 G&apos; + 0.114 B&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;关键代码&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;if (isImageB) {
	// 处理黑色背景的图像
	const pixelData = blackCtx.getImageData(coordX, coordY, 1, 1).data;
	// 计算灰度值
	const gray = Math.round(pixelData[0] * 0.299 + pixelData[1] * 0.587 + pixelData[2] * 0.114);
	// 设置像素值（黑色，透明度基于灰度值）
	resultData.data[idx] = 0;     // R
	resultData.data[idx+1] = 0;   // G
	resultData.data[idx+2] = 0;   // B
	resultData.data[idx+3] = 255 - gray; // A
} else {
	// 处理白色背景的图像
	const pixelData = whiteCtx.getImageData(coordX, coordY, 1, 1).data;
	// 计算灰度值
	const gray = Math.round(pixelData[0] * 0.299 + pixelData[1] * 0.587 + pixelData[2] * 0.114);
	// 设置像素值（白色，透明度基于灰度值）
	resultData.data[idx] = 255;   // R
	resultData.data[idx+1] = 255; // G
	resultData.data[idx+2] = 255; // B
	resultData.data[idx+3] = gray; // A
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;补充&lt;/h1&gt;
&lt;p&gt;由于实现方法不太一样，所以我这里的只能生成灰度图，原图还是带了点其他颜色的，不过他的效果不是很好，在白色背景下也会显示出部分图 B。&lt;/p&gt;
&lt;p&gt;试玩可以到&lt;a href=&quot;https://www.doiduoyi.com/?app=IMAGEMERGE&quot;&gt;这里&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>解决 Tauri 文件拖放无效问题</title><link>https://blog.erio.work/posts/%E8%A7%A3%E5%86%B3tauri%E6%96%87%E4%BB%B6%E6%8B%96%E6%94%BE%E6%97%A0%E6%95%88%E9%97%AE%E9%A2%98/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%A7%A3%E5%86%B3tauri%E6%96%87%E4%BB%B6%E6%8B%96%E6%94%BE%E6%97%A0%E6%95%88%E9%97%AE%E9%A2%98/</guid><description>解决文件拖放失效的两种方法与优劣</description><pubDate>Tue, 01 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;点进这篇文章的应该都是在 Tauri 拖拽时踩了坑的，按照惯用的方案，文件拖放一般会通过 &lt;code&gt;onDrag&lt;/code&gt;、&lt;code&gt;onDrop&lt;/code&gt; 实现，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const handleDrag = (e: React.DragEvent&amp;lt;HTMLDivElement&amp;gt;) =&amp;gt; {
	e.preventDefault();
	e.stopPropagation();
	if (e.type === &apos;dragenter&apos; || e.type === &apos;dragover&apos;) {
		setDragActive(true);
	} else if (e.type === &apos;dragleave&apos;) {
		setDragActive(false);
	}
};

const handleDrop = (e: React.DragEvent&amp;lt;HTMLDivElement&amp;gt;) =&amp;gt; {
	e.preventDefault();
	e.stopPropagation();
	setDragActive(false);
	if (e.dataTransfer.files &amp;amp;&amp;amp; e.dataTransfer.files.length &amp;gt; 0) {
		const filesArray = Array.from(e.dataTransfer.files);
		handleFileUpload(filesArray);
	}
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种&lt;strong&gt;写法没有问题&lt;/strong&gt;，但在 Tauri 中，&lt;strong&gt;默认是无效的&lt;/strong&gt;，也就是你把&lt;strong&gt;文件拖上去根本触发不了 &lt;code&gt;drag&lt;/code&gt;、&lt;code&gt;drop&lt;/code&gt; 事件&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个实际上是 Tauri 的问题，不是前端代码问题，Tauri 托管了 Webview 的相关事件，所以事件会先走到他那层，再由他决定是否转发给前端，对应的解决方法就有两种。&lt;/p&gt;
&lt;h1&gt;通过 Tauri 事件&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;listen(&apos;tauri://file-drop&apos;, event =&amp;gt; {
  console.log(event)
})
// 对应的还有 tauri://file-drop-cancelled 和 tauri://file-drop-hover
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接在 Tauri 层捕获拖放事件，通过回调通知前端去处理。&lt;/p&gt;
&lt;p&gt;要注意这里拿到的只有文件路径，所以你还得通过 Tauri 的 fs 去读取文件内容（有坑，请参考往期文章）。&lt;/p&gt;
&lt;h1&gt;通过配置&lt;/h1&gt;
&lt;p&gt;修改 &lt;code&gt;tauri.conf.json&lt;/code&gt; 文件，在 &lt;code&gt;tauri.windows&lt;/code&gt; 中，给项目加上 &lt;code&gt;fileDropEnabled&lt;/code&gt;，并设置为 &lt;code&gt;false&lt;/code&gt;，这样前端代码不需要动，就能在 &lt;em&gt;&lt;strong&gt;Windows&lt;/strong&gt;&lt;/em&gt; 下生效。&lt;/p&gt;
&lt;p&gt;这种改法最简单，但兼容性比较差，&lt;code&gt;fileDropEnabled&lt;/code&gt; 控制“&lt;strong&gt;是否在 webview 上启用文件拖放功能&lt;/strong&gt;”，默认是 &lt;code&gt;true&lt;/code&gt;，但在 &lt;em&gt;&lt;strong&gt;Windows&lt;/strong&gt;&lt;/em&gt; 下只有为 &lt;code&gt;false&lt;/code&gt; 的时候才能在前端正确处理拖放事件。&lt;/p&gt;
&lt;p&gt;至于如何做平台的判断，文档也没说。。。&lt;/p&gt;
</content:encoded></item><item><title>整理 TailwindCSS 中的复合样式</title><link>https://blog.erio.work/posts/%E6%95%B4%E7%90%86tailwindcss%E4%B8%AD%E7%9A%84%E5%A4%8D%E5%90%88%E6%A0%B7%E5%BC%8F/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E6%95%B4%E7%90%86tailwindcss%E4%B8%AD%E7%9A%84%E5%A4%8D%E5%90%88%E6%A0%B7%E5%BC%8F/</guid><description>整理并介绍复合样式，别老写 w-full h-full 了</description><pubDate>Mon, 31 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;TailwindCSS 很好用，我用上瘾了，但写的时候补全总会出现一些没见过的样式命名，所以我去重新看了文档，整理了一下。&lt;/p&gt;
&lt;p&gt;我把他的样式分成两类，一是简单语句，二是复合语句，比如 &lt;code&gt;top-0&lt;/code&gt; 这种就是简单语句，因为他控制的就是 &lt;code&gt;top&lt;/code&gt; 样式，而复合语句，我对他的定义是&lt;strong&gt;一条语句能操作多个不同类的 CSS 样式，且命名方式不按 CSS 规律&lt;/strong&gt;，比如 &lt;code&gt;truncate&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;复合语句能有效减少代码量，可能短短几个字母就能把一大串简单语句的功能覆盖。&lt;/p&gt;
&lt;p&gt;由于 tailwind 的文档中没有单独把他们归类，所以本文主要会列出我能找到的所有复合语句进行介绍。&lt;/p&gt;
&lt;h1&gt;truncate&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就是文字过长显示省略号的“简写”，在父元素宽度确定的前提下，一句就能取代 &lt;code&gt;overflow-hidden text-ellipsis whitespace-nowrap&lt;/code&gt;。&lt;/p&gt;
&lt;h1&gt;divide-x/y&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;&amp;amp; &amp;gt; :not(:last-child) {
	border-inline-start-width: 0px;
	border-inline-end-width: 1px;
}

&amp;amp; &amp;gt; :not(:last-child) {
	border-top-width: 0px;
	border-bottom-width: 1px;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;效果是给多个元素&lt;strong&gt;之间&lt;/strong&gt;加上分隔线，分隔线是通过 &lt;code&gt;border&lt;/code&gt; 实现的。&lt;/p&gt;
&lt;p&gt;因为是 &lt;code&gt;border&lt;/code&gt; 所以最好用在&lt;strong&gt;元素之间没有间隔的场景&lt;/strong&gt;，在带有 &lt;code&gt;gap&lt;/code&gt; 场景不适用，毕竟这种时候你还得手动设置元素的 &lt;code&gt;padding&lt;/code&gt; 调整边界与元素之间的距离。&lt;/p&gt;
&lt;h1&gt;divide-solid/dotted/dashed 与 divide-(color)&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;&amp;amp; &amp;gt; :not(:last-child) {
	border-style: solid;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;是配合 &lt;code&gt;divide-x/y&lt;/code&gt; 使用的，看样式就知道 &lt;code&gt;divide-x/y&lt;/code&gt; 只是设置了 &lt;code&gt;border&lt;/code&gt; 的宽度，连 &lt;code&gt;border&lt;/code&gt; 的 &lt;code&gt;color&lt;/code&gt;、&lt;code&gt;style&lt;/code&gt; 都没有定义，所以需要用额外的 &lt;code&gt;divide&lt;/code&gt; 语句去描述。&lt;/p&gt;
&lt;h1&gt;space-x/y 与 space-x/y-reverse&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;&amp;amp; &amp;gt; :not(:last-child) {
	--tw-space-x-reverse: 0;
	margin-inline-start: calc(calc(var(--spacing) * &amp;lt;number&amp;gt;) * var(--tw-space-x-reverse));
	margin-inline-end: calc(calc(var(--spacing) * &amp;lt;number&amp;gt;) * calc(1 - var(--tw-space-x-reverse)));
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;和 &lt;code&gt;divide&lt;/code&gt; 类似，但用途相反，这个是基于 &lt;code&gt;margin&lt;/code&gt; 的复合语句，用在&lt;strong&gt;元素之间需要间隔的场景&lt;/strong&gt;，通过 &lt;code&gt;margin&lt;/code&gt; 将元素之间分隔开。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;space-x/y-reverse&lt;/code&gt; 是 &lt;code&gt;space-x/y&lt;/code&gt; 的补丁，在元素反向排列的场景下，就应该一起用。&lt;/p&gt;
&lt;p&gt;以只使用 &lt;code&gt;space-x&lt;/code&gt; 为例，他会给每个元素（非最后一个）的右侧加上 &lt;code&gt;margin&lt;/code&gt;，如 &lt;code&gt;1(空)2(空)3&lt;/code&gt;，但在 &lt;code&gt;flex-reverse&lt;/code&gt; 的情况下，就会变成 &lt;code&gt;32(空)1(空)&lt;/code&gt;，&lt;code&gt;space-x/y-reverse&lt;/code&gt; 就是修复这种情况的。&lt;/p&gt;
&lt;h1&gt;sr-only 与 not-sr-only&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;

position: static;
width: auto;
height: auto;
padding: 0;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可能是接触比较少的功能了，&lt;code&gt;sr-only&lt;/code&gt; 用于定义某些元素只能在屏幕阅读器上查看，在普通屏幕上看不到，&lt;code&gt;not-sr-only&lt;/code&gt; 则反过来。&lt;/p&gt;
&lt;h1&gt;size-*&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;width: *;
height: *;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用于给 &lt;code&gt;width&lt;/code&gt; 和 &lt;code&gt;height&lt;/code&gt; 设置同一个值，比如 &lt;code&gt;size-full&lt;/code&gt; 就是宽高均为 100%。&lt;/p&gt;
&lt;h1&gt;line-clamp&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: &amp;lt;number&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;和 &lt;code&gt;truncate&lt;/code&gt; 类似，&lt;code&gt;line-clamp&lt;/code&gt; 就是设置显示多少行后省略，整合了一套模版写法。&lt;/p&gt;
&lt;h1&gt;ring&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;--tw-ring-shadow: 0 0 0 1px;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ring&lt;/code&gt; 是 &lt;code&gt;outline&lt;/code&gt; 的一种替代，以 &lt;code&gt;box-shadow&lt;/code&gt; 实现，两者都能设置 offset，但 &lt;code&gt;ring&lt;/code&gt; 在环和元素的中间可以设置颜色，而 &lt;code&gt;outline&lt;/code&gt; 不行，只能是透明的。&lt;/p&gt;
</content:encoded></item><item><title>使用 Cursor 接入其他 LLM 服务</title><link>https://blog.erio.work/posts/%E4%BD%BF%E7%94%A8cursor%E6%8E%A5%E5%85%A5%E5%85%B6%E4%BB%96llm%E6%9C%8D%E5%8A%A1/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E4%BD%BF%E7%94%A8cursor%E6%8E%A5%E5%85%A5%E5%85%B6%E4%BB%96llm%E6%9C%8D%E5%8A%A1/</guid><description>使用便宜的 LLM API 接入 Cursor 实现一定程度的白嫖</description><pubDate>Wed, 05 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Cursor 我用了大半年了，钱也交了 4 个月了吧，挺好用的，但真的太贵了，我只拿他来做问答，然后给我代码我自己去改，光这个功能一个月要收我 100 多，真的不值。&lt;/p&gt;
&lt;p&gt;想到上个月白嫖了硅基流动的 2000 万 token，我就琢磨能不能把这些免费的 API 接入 Cursor 呢？答案是能的，首先要打开 Cursor 的设置：
&lt;img src=&quot;20250305175020.png&quot; alt=&quot;&quot; /&gt;
选中 Models，取消掉其他的模型名称，添加你能用的模型名。&lt;/p&gt;
&lt;p&gt;然后修改 OpenAI 的设置：
&lt;img src=&quot;20250305175125.png&quot; alt=&quot;&quot; /&gt;
把你用的服务的 key 填进去，然后修改一下 Base URL，点保存。&lt;/p&gt;
&lt;p&gt;可以用 Verify 验证一下，报错不代表失败，看看具体的错误，如果只是模型名称不对没关系，因为他会默认用 default 去测试，这个当然行不通，只要不是服务不通就行。&lt;/p&gt;
&lt;p&gt;接着打开 chat 面板，选择正确的模型：
&lt;img src=&quot;20250305175339.png&quot; alt=&quot;&quot; /&gt;
这里第一次选会弹窗提示，点确定就好了。&lt;/p&gt;
&lt;p&gt;到这里就完成了，但我还是没想明白，为什么第三方服务接入要修改 OpenAI 的设置呢，而不是提供一个第三方的 OpenAI-like 选项，这不就默认了不认识的模型名全是 OpenAI 的吗？&lt;/p&gt;
</content:encoded></item><item><title>使用回溯解决 Stitch 中的谜题</title><link>https://blog.erio.work/posts/%E4%BD%BF%E7%94%A8%E5%9B%9E%E6%BA%AF%E8%A7%A3%E5%86%B3stitch%E4%B8%AD%E7%9A%84%E8%B0%9C%E9%A2%98/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E4%BD%BF%E7%94%A8%E5%9B%9E%E6%BA%AF%E8%A7%A3%E5%86%B3stitch%E4%B8%AD%E7%9A%84%E8%B0%9C%E9%A2%98/</guid><description>Stitch 是一个需要在网格中，根据指示的数字，放入对应面积的方块，最终将所有网格填满的解密游戏</description><pubDate>Tue, 25 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这个 &lt;a href=&quot;https://apps.apple.com/us/app/stitch/id1581052096?l=zh&quot;&gt;Stitch&lt;/a&gt; 就是我 Apple Arcade 文章中提到的游戏，玩家需要在网格中根据提示的数字，画出指定大小的方块，填满整个网格。&lt;/p&gt;
&lt;p&gt;说实在的，我不知道他这种不规则的谜题的数据结构应该怎么定义，所以我这段解题代码只能用于&lt;strong&gt;规则矩形&lt;/strong&gt;的谜题，每日一题里的那种。&lt;/p&gt;
&lt;h1&gt;代码&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;#[derive(Debug, Clone)]
struct Block {
  w: usize,
  h: usize,
  x: usize,
  y: usize,
}

const GRID_WIDTH: usize = 8;
const GRID_HEIGHT: usize = 14;

struct Solver {
  cnt: usize,
  blocks: Vec&amp;lt;Vec&amp;lt;Block&amp;gt;&amp;gt;,
  grid: [[usize; GRID_WIDTH]; GRID_HEIGHT],
  tag: [[usize; GRID_WIDTH]; GRID_HEIGHT],
  found: bool,
  result: Option&amp;lt;[[usize; GRID_WIDTH]; GRID_HEIGHT]&amp;gt;,
}

impl Solver {
  fn new(grid: [[usize; GRID_WIDTH]; GRID_HEIGHT]) -&amp;gt; Self {
    Self {
      cnt: 0,
      blocks: vec![Vec::new(); 100],
      grid,
      tag: [[0; GRID_WIDTH]; GRID_HEIGHT],
      found: false,
      result: None,
    }
  }

  fn dfs(&amp;amp;mut self, st: usize) {
    if self.found {
      return;
    }
    if st == self.cnt {
      self.found = true;
      self.result = Some(self.tag);
      return;
    }

    // 先收集所有可能的块到一个临时向量中
    let current_blocks: Vec&amp;lt;Block&amp;gt; = self.blocks[st].clone();
    for block in current_blocks {
      let mut valid = true;
      // 检查是否可以放置
      for i in 0..block.w {
        for j in 0..block.h {
          let xx = block.x + i;
          let yy = block.y + j;
          if self.tag[xx][yy] != 0 {
            valid = false;
            break;
          }
        }
        if !valid {
          break;
        }
      }
      if !valid {
        continue;
      }

      // 放置方块
      for i in 0..block.w {
        for j in 0..block.h {
          self.tag[block.x + i][block.y + j] = st + 1;
        }
      }

      self.dfs(st + 1);

      if self.found {
        return;
      }

      // 回溯，移除方块
      for i in 0..block.w {
        for j in 0..block.h {
          self.tag[block.x + i][block.y + j] = 0;
        }
      }
    }
  }

  fn solve(&amp;amp;mut self) -&amp;gt; Option&amp;lt;[[usize; GRID_WIDTH]; GRID_HEIGHT]&amp;gt; {
    // 先找出所有数字格子
    for i in 0..GRID_HEIGHT {
      for j in 0..GRID_WIDTH {
        if self.grid[i][j] &amp;gt; 0 {
          let area = self.grid[i][j];
          // 枚举所有可能的长和宽
          for w in 1..=area {
            if area % w == 0 {
              let h = area / w;
              // 枚举所有可能的位置
              for x in i.saturating_sub(w - 1)..=i {
                for y in j.saturating_sub(h - 1)..=j {
                  if x + w &amp;lt;= GRID_HEIGHT &amp;amp;&amp;amp; y + h &amp;lt;= GRID_WIDTH {
                    // 检查是否只覆盖了一个数字
                    let mut count = 0;
                    let mut valid = true;
                    for dx in 0..w {
                      for dy in 0..h {
                        if self.grid[x + dx][y + dy] &amp;gt; 0 {
                          count += 1;
                          if count &amp;gt; 1 {
                            valid = false;
                            break;
                          }
                        }
                      }
                      if !valid {
                        break;
                      }
                    }
                    if valid &amp;amp;&amp;amp; count == 1 {
                      self.blocks[self.cnt].push(Block { w, h, x, y });
                    }
                  }
                }
              }
            }
          }
          self.cnt += 1;
        }
      }
    }
    // 开始搜索
    self.dfs(0);
    self.result
  }
}

fn main() {
  let mut solver = Solver::new(grid);
  solver.solve();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;思路&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;Block&lt;/code&gt; 表示玩家绘制的方块信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x&lt;/code&gt; 与 &lt;code&gt;y&lt;/code&gt; 为坐标&lt;/li&gt;
&lt;li&gt;&lt;code&gt;w&lt;/code&gt; 与 &lt;code&gt;h&lt;/code&gt; 为尺寸&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;GRID_WIDTH&lt;/code&gt; 和 &lt;code&gt;GRID_HEIGHT&lt;/code&gt; 是网格的尺寸，每日一题中的网格就是 14 行 8 列的。
谜题网格中的数字是需要填入的方块的面积，后文会称作&lt;strong&gt;子谜题&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Solver&lt;/code&gt; 记录着一些解题过程中的状态：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cnt&lt;/code&gt; 表示当前处理的子谜题编号&lt;/li&gt;
&lt;li&gt;&lt;code&gt;blocks&lt;/code&gt; 表示每个子谜题&lt;strong&gt;可能填入的方块&lt;/strong&gt;的位置和尺寸，是个二维数组，子谜题编号作为“键”&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grid&lt;/code&gt; 是谜题网格，一个二维数组&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tag&lt;/code&gt; 为标记表，将已放置的方块具体填入网格中，如 &lt;code&gt;[[1, 1, 2, 3, 3, 3]]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;found&lt;/code&gt; 为找到最终解的标志&lt;/li&gt;
&lt;li&gt;&lt;code&gt;result&lt;/code&gt; 为最终解，是 &lt;code&gt;tag&lt;/code&gt; 的引用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;预处理：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先找出谜题里带数字的格子，也就是我们要填入的每个&lt;strong&gt;方块的面积&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;然后通过面积计算出&lt;strong&gt;所有的长和宽组合&lt;/strong&gt;，比如 2 可以拆分成 w1 h2 或者 w2 h1&lt;/li&gt;
&lt;li&gt;把所有的组合，按不同的摆放方式去带入棋盘，主要是检查是否&lt;strong&gt;只覆盖了当前子谜题&lt;/strong&gt;，排除不可能的摆放&lt;/li&gt;
&lt;li&gt;将剩余的可能存入 &lt;code&gt;blocks&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;20250225161509.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;接着使用深度优先搜索，计算合适的摆放方法：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;按子谜题编号逐个尝试，使用 &lt;code&gt;blocks&lt;/code&gt; 中预存的方块，进行摆放&lt;/li&gt;
&lt;li&gt;放置结果保存在 &lt;code&gt;tag&lt;/code&gt; 中，要保证不和其他子谜题的答案冲突&lt;/li&gt;
&lt;li&gt;放置成功后就递归处理下一个子谜题&lt;/li&gt;
&lt;li&gt;遇到所有摆放都不可能的子谜题，那就是之前的摆放有错，回到上一步，继续尝试&lt;/li&gt;
&lt;li&gt;成功处理完所有子谜题后，设置 &lt;code&gt;found&lt;/code&gt; 标志，结束搜索&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;效果&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;6bac3bb90f0b4ef02b6d31a2b88a011.png&quot; alt=&quot;&quot; /&gt;
&lt;img src=&quot;20250225170552.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>锐评 Apple Arcade</title><link>https://blog.erio.work/posts/%E9%94%90%E8%AF%84applearcade/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E9%94%90%E8%AF%84applearcade/</guid><description>对 Apple Arcade 的一些吐槽，以及和 Google Play Pass 的对比</description><pubDate>Mon, 24 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Arcade 我开了有 5 年，现在还在积极使用，但有些槽点也是不吐不快。我订阅的是 Apple One，里面附带了 Arcade，说实话 One 还挺划算的，尤其是家庭组，大家能分担年费，如果要单买 Arcade，那我觉得不值，别买。&lt;/p&gt;
&lt;p&gt;先说游戏品质，里面游戏的质量还是过得去的，和竞品 Google Play Pass 比，那算是精品了，Play Pass 里的游戏大多是滥竽充数的，就像是搜 Idle 刷屏的那些游戏的品质，只不过把 IAP 去掉了。&lt;/p&gt;
&lt;p&gt;相较之下，Arcade 里的主要是重新开发的游戏，如果是&lt;strong&gt;现有游戏的增强版&lt;/strong&gt;，，那么厂商会在其基础上加一些内容（也有纯移植），命名变成 xxx+，比如 Balatro+，我是个 PC 游戏玩家，所以这些知名独立游戏我早在 PC 上玩过了，等 Arcade 定制版发布的时候，我早就玩吐了，所以这类“plus 版”游戏对我没有吸引力。&lt;/p&gt;
&lt;p&gt;Arcade 还有一类是&lt;strong&gt;竞技游戏&lt;/strong&gt;，他们通常是全新开发的，不会出现现有手游上 Arcade 的情况，即便有相同类型的，那肯定也是“模仿”的，这类游戏确实是公平竞技，没有 IAP，装备全靠等级解锁、金币购买，但你玩起来就会觉得“他怎么和常见的手游就是不太一样呢？”。&lt;/p&gt;
&lt;p&gt;后面我自己想了一下，我认为应该是缺少了各种保日活的手段，比如没有签到，没有月卡，甚至没有进入游戏后铺天盖地的活动宣传，他就是一个很纯粹的对战游戏，纯粹到一局的奖励只有金币和经验，如果你不想在线 PVP（不同意资料分享的协议），他们还会提供一套纯离线的 AI，简直是梦回 21 世纪初。&lt;/p&gt;
&lt;p&gt;但这种游戏我玩几天就腻了，因为没啥目标，你能获得的游戏资源就那两种，他要卡你的进度，那就是卡这两项资源，你没有其他选择余地，无法跳过去玩别的，因为没有别的内容了，目前我玩过的好几款 PVP 游戏都停止更新了，毕竟这种模式也没什么需要运营的地方。&lt;/p&gt;
&lt;p&gt;剩下的一类就是&lt;strong&gt;真·独立游戏&lt;/strong&gt;了，但多数是现有热门游戏的换皮，比如三消的换皮有神庙逃亡（三消版）、ZooKeeper World，Garden Tails 等，这类我还挺喜欢的，毕竟你不可能在商店里找到&lt;strong&gt;没有生命限制的三消游戏&lt;/strong&gt;，真能无限玩，那可太难得了。&lt;/p&gt;
&lt;p&gt;但这类游戏的手感和原作总是差了那么一丢丢，我这里会与经典的 Candy Crush 做比较，Candy Crush 的手感这些 Arcade 游戏就是学不来，玩起来就是会不跟手，但总比没有好。&lt;/p&gt;
&lt;p&gt;全新开发不换皮的精品也有，比如 Stitch，还有一些我忘了名字，都在 Arcade 首页的分类中，精品都上了/上过推荐。&lt;/p&gt;
&lt;p&gt;上了的游戏是&lt;strong&gt;限时&lt;/strong&gt;的，过了时间就会下架，不是说你下载到手机里就能一直玩下去，我 20 年玩过一个挺不错的飞船养成游戏，但过了一两年想重温的时候，却发现游戏已经下架了，在手机里的那一份也无法启动，问就是已下架。&lt;/p&gt;
&lt;p&gt;难受的是，一些游戏下架之后就不会选择再上架了，永远消失在了 App Store 中，甚至都不会选择发布带 IAP 的版本或者移植其他平台。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;吐槽方面，Arcade 都出那么多年了，该死的苹果还是不愿意给出一份完整的 Arcade 游戏列表，都不知道他们在担心什么，全靠爱好者自己统计收集，然后记录在第三方百科中，App Store 的分类又很差劲，可以说只有编辑推荐，就算是分类，你在里面也看不到所有的游戏，搜索可以，但你又怎么知道那些冷门游戏的名字呢？&lt;/p&gt;
</content:encoded></item><item><title>记一次树莓派问题排查过程</title><link>https://blog.erio.work/posts/%E8%AE%B0%E4%B8%80%E6%AC%A1%E6%A0%91%E8%8E%93%E6%B4%BE%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5%E8%BF%87%E7%A8%8B/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%AE%B0%E4%B8%80%E6%AC%A1%E6%A0%91%E8%8E%93%E6%B4%BE%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5%E8%BF%87%E7%A8%8B/</guid><description>从 Docker 应用无法访问到间歇性断连，持续了一周时间的排查过程</description><pubDate>Sun, 23 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;过程&lt;/h1&gt;
&lt;p&gt;是这样的，我最近需要跑一些常驻的服务，比如 AdGuard、HomeAssistant 等，于是掏出了 10 年前的树莓派 3B+，装了最新的 bookworm 系统。&lt;/p&gt;
&lt;p&gt;一开始没什么问题（复盘发现&lt;strong&gt;只是我没碰上&lt;/strong&gt;），有一天我晚上要用手机回个消息，然后我发现断网了，我刚开始以为是电信日常的重拨，之后发现其他设备都正常啊，怎么就手机连不上，第一时间就想到是 AdGuard 出了问题，因为我只给手机加了 DNS 反广告。&lt;/p&gt;
&lt;p&gt;电脑上一查，确实，AdGuard 页面打不开了，于是我就 ssh 上去看看问题，结果 ssh 也连不上，上路由器后台一看，设备列表里都没他了，那只好试试网线了，结果网线接上去也没反应，连不上，只好强制重启。&lt;/p&gt;
&lt;p&gt;重启过后一切正常，什么问题都消失了，这时候我的猜想是偶发性的，某个应用死循环了，把 &lt;strong&gt;CPU、内存等资源吃满了，所以系统高负载服务都未响应&lt;/strong&gt;了。&lt;/p&gt;
&lt;p&gt;后来第二天、第三天晚上都出现了这个问题，这已经不是偶发性的了，就算是应用出 BUG，那也是严重 BUG 了，于是我把当时的嫌疑应用 AdGuard 给停了，但第四天依然出现，后面甚至变成了无法正常开机的情况，点亮后一直无法联网。&lt;/p&gt;
&lt;p&gt;于是我找了一条 HDMI 线，插上屏幕，屏幕能点亮，能进系统，但是鼠标、键盘全失效，没有反应，这时候我懵逼了，我开始&lt;strong&gt;怀疑是树莓派出问题&lt;/strong&gt;了，毕竟用了 10 年，所以我下单了 PI 5。&lt;/p&gt;
&lt;p&gt;同一张 tf 卡，插入 PI 5 之后是能正常使用的，我一开始以为事情就这样解决了，但第二天依然出现了无法连接的问题，由于我用的是&lt;strong&gt;金属外壳，被动散热&lt;/strong&gt;的，在出问题的时候设备会变得很烫，大概 50 度。&lt;/p&gt;
&lt;p&gt;我开始怀疑是系统问题，于是备份后重新安装了系统，使用了 Lite 版，一开始我没有安装 Docker 等应用，放了一天，没有复现（这里我测试的时候有些纰漏，只查了温度，没看路由器设备列表）。&lt;/p&gt;
&lt;p&gt;随后我第三天晚上继续安装了 HomeAssistant 等容器，也没有出现问题，但到第四天的时候，问题又来了，新安装的应用和 3B+ 上的完全不一样，所以我这时候排除了应用 BUG 的可能，或者说&lt;strong&gt;不是应用主导的问题&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;现在又回到了系统负载上，这个点很难去搜索，太广泛了。正当我头疼的时候，我想到了一个角度：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;会不会是因为网络断连，所以导致一些应用反复重连/自检，从而进入死循环，然后引发系统高负载发烫呢？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;于是我用“树莓派 网络自动断连”这种关键词去搜索，还真给我搜到了一个特性，那就是树莓派他默认是有 WiFi 休眠的，你长时间不用，他就会把 WiFi 断掉，为了省电，但如何恢复，在 headless 环境中我真想不到（没有网络，也没有 GUI）。&lt;/p&gt;
&lt;p&gt;用命令 &lt;code&gt;iw wlan0 get power_save&lt;/code&gt; 检查休眠状态，确实是开启的，用命令 &lt;code&gt;iw dev wlan0 set power_save off&lt;/code&gt; 禁用后只是当前环境生效，如果要永久禁用，按 &lt;a href=&quot;https://raspberrypi.stackexchange.com/questions/96606/make-iw-wlan0-set-power-save-off-permanent&quot;&gt;StackOverflow&lt;/a&gt; 的解决办法是创建一个新服务，让这个服务开机启动，自动运行禁用休眠的命令。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;事实上，到这一步事情&lt;strong&gt;还没有完全结束&lt;/strong&gt;，休眠的问题解决了，早上起来设备确实没有发烫，但是 ssh 连接依然很慢，不过也不至于上不去，输入的时候也会有些卡顿，查了半天，又是设置 QoS，又是设置 ssh 不使用 DNS，还是解决不了。&lt;/p&gt;
&lt;p&gt;后来我尝试 &lt;code&gt;ping&lt;/code&gt; 了一下，发现在局域网内的延迟都有 5ms，那估计是信号不好了。&lt;/p&gt;
&lt;p&gt;在我无头绪乱投医的时候，我注意到一个可能的问题，如果你认真看了前文，你可能也会注意到，我 PI 5 的&lt;strong&gt;壳是金属的&lt;/strong&gt;，被动散热，除了底部是塑料，他就是一个金属盒子，然后我用的又是 WiFi 联网，就有&lt;strong&gt;可能是外壳屏蔽了信号&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;说实话到这一步我已经无语了，这已经不是改设置就能解决的，感觉之前的努力都白费了，只能掏出网线，&lt;strong&gt;老实使用有线连接&lt;/strong&gt;，之后经过测试，有线连接后 ping 的延迟小于 1ms，是正常水平。&lt;/p&gt;
&lt;h1&gt;服务&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl --full --force edit wifi_powersave@.service

# wifi_powersave@.service
[Unit]
Description=Set WiFi power save %i
After=sys-subsystem-net-devices-wlan0.device

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/sbin/iw dev wlan0 set power_save %i

[Install]
WantedBy=sys-subsystem-net-devices-wlan0.device
# ----------------------

sudo systemctl disable wifi_powersave@on.service
sudo systemctl enable wifi_powersave@off.service
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;总结&lt;/h1&gt;
&lt;p&gt;经过我后面复盘，大概是这么几点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;树莓派 3B+ 确实是坏了，也有可能是那几天的高温导致的&lt;/li&gt;
&lt;li&gt;此前（几年前）用的时候为什么没发现休眠？因为之前都是连接网线的&lt;/li&gt;
&lt;li&gt;问题原因为系统默认开启的 WiFi 休眠&lt;/li&gt;
&lt;li&gt;金属壳会屏蔽信号，别想着用 WiFi 了&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;吐槽&lt;/h1&gt;
&lt;p&gt;你说 3 拿来当低功耗的硬件使用也就算了，5 性能比 3 强那么多，还要求 35w 电源，你官方怎么能默认把他当做低功耗设备来配置啊？这合理吗？&lt;/p&gt;
</content:encoded></item><item><title>用 DeepSeek 协助开发 UserScript</title><link>https://blog.erio.work/posts/%E7%94%A8deepseek%E5%8D%8F%E5%8A%A9%E5%BC%80%E5%8F%91userscript/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E7%94%A8deepseek%E5%8D%8F%E5%8A%A9%E5%BC%80%E5%8F%91userscript/</guid><description>让 AI 去考虑开发中的问题，以及让他去实现代码，自己再负责去调整</description><pubDate>Fri, 21 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;前几天看到一个视频，我觉得其中一段很有意思，就打算分享给群友，链接发出去附上秒数，结果没多少人看，这时候我就在想手头上要有个 gif 录制工具就好了，毕竟搬屎的时候发动图，群友不得不看。&lt;/p&gt;
&lt;p&gt;本着一颗折腾的心，我打算整个脚本来实现，这功能做起来我心里有点数，但不确定相关 API 能不能支持我这样做，理论上很简单，就是在视频流中得到一系列的帧图像数据，然后用这些图像构建出一个 gif 文件。&lt;/p&gt;
&lt;p&gt;如果要调整分辨率的话，可能还需要把图像画到 canvas 里面去做处理，再写到 gif。&lt;/p&gt;
&lt;p&gt;这里我不确定的是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;H5 API 中的 video，能不能让我拿到当前帧，或者指定某一帧的图像，毕竟可能会有跨域问题，比如像把没有跨域的图片写到 canvas 后，无法 &lt;code&gt;getImageData&lt;/code&gt; 那样&lt;/li&gt;
&lt;li&gt;如果能拿到，那么图像数据的格式不确定，能不能直接放进 canvas 也不好说，毕竟我没做过&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;想到这些不确定的地方，又开始懒起来了，怕做到一半发现做不下去了，浪费时间，干脆用 DeepSeek 先做吧，之后自己再改改。&lt;/p&gt;
&lt;h1&gt;回应&lt;/h1&gt;
&lt;p&gt;DeepSeek 给出的方案是这样的，他似乎没有理解 UserScript 的意思，不过问题不大。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;a. 注入内容脚本获取视频元素&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// content-script.js
const videoElements = document.querySelectorAll(&apos;video&apos;);
// 向插件后台发送视频信息
chrome.runtime.sendMessage({ type: &apos;VIDEO_FOUND&apos;, videos: Array.from(videoElements).map(v =&amp;gt; v.src) });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;b. 用户界面（UI）&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在页面右上角添加浮动控件（Shadow DOM 避免样式污染）。&lt;/li&gt;
&lt;li&gt;用户输入或拖动时间轴选择起止时间（需监听视频的 &lt;code&gt;timeupdate&lt;/code&gt; 事件）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;c. 捕获视频帧&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const canvas = document.createElement(&apos;canvas&apos;);
const ctx = canvas.getContext(&apos;2d&apos;);

// 逐帧捕获（需处理性能优化）
video.currentTime = startTime;
await new Promise(r =&amp;gt; video.onseeked = r);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;d. 生成 GIF&lt;/strong&gt;
推荐使用 &lt;strong&gt;&lt;a href=&quot;https://github.com/jnordberg/gif.js&quot;&gt;gif.js&lt;/a&gt;&lt;/strong&gt; 或 &lt;strong&gt;&lt;a href=&quot;https://github.com/ffmpegwasm/ffmpeg.wasm&quot;&gt;ffmpeg.wasm&lt;/a&gt;&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const gif = new GIF({
  workers: 2,
  quality: 10,
  width: canvas.width,
  height: canvas.height
});

gif.addFrame(imageData, { delay: 100 }); // 每帧延迟
gif.on(&apos;finished&apos;, blob =&amp;gt; {
  const url = URL.createObjectURL(blob);
  // 触发下载
  chrome.downloads.download({ url, filename: &apos;clip.gif&apos; });
});
gif.render();
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;跨域限制（CORS）&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需在 &lt;code&gt;manifest.json&lt;/code&gt; 中声明权限：&lt;pre&gt;&lt;code&gt;&quot;permissions&quot;: [&quot;activeTab&quot;, &quot;downloads&quot;, &quot;scripting&quot;, &quot;storage&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;对跨域视频可能需要用户手动启用权限（如点击插件图标授权）。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;DRM 保护视频&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;无法处理受保护的流媒体（如 Netflix、YouTube Premium），需提示用户不支持。&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;ul&gt;
&lt;li&gt;降低 GIF 分辨率（如 320x180）。&lt;/li&gt;
&lt;li&gt;限制帧率（如 10 FPS）。&lt;/li&gt;
&lt;li&gt;使用 Web Worker 避免主线程阻塞。&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;ul&gt;
&lt;li&gt;仅支持 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 标签，部分网站的自定义播放器需额外适配（如通过截图模拟）。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&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;code&gt;SecurityError&lt;/code&gt;（跨域）和 &lt;code&gt;NotAllowedError&lt;/code&gt;（DRM）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;代码与调整&lt;/h1&gt;
&lt;p&gt;我也去查了一下 &lt;code&gt;drawImage&lt;/code&gt;，第一个参数可以是 video element，也不用特别指定某一帧，把 &lt;code&gt;currentTime&lt;/code&gt; 设置好，canvas 就能自动通过 element 去拿到当前帧，以下是具体实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ==UserScript==
// @name         Video to GIF Converter
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  Convert video clips to GIF directly in the browser
// @author       Your Name
// @match        *://*/*
// @grant        GM_addStyle
// @grant        GM_getResourceURL
// @grant        GM_download
// @require      https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js
// @resource     gifWorker https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js
// @run-at       document-end
// ==/UserScript==

(function() {
  &apos;use strict&apos;;

  // 全局样式
  GM_addStyle(`
    .gif-converter-wrapper {
      position: fixed;
      bottom: 20px;
      right: 20px;
      z-index: 9999;
      font-family: Arial, sans-serif;
    }

    #gifTriggerBtn {
      padding: 10px 20px;
      background: #2196F3;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }

    .config-panel {
      background: white;
      border: 1px solid #ccc;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
      width: 300px;
      margin-top: 10px;
    }

    .config-row {
      margin: 10px 0;
    }

    label {
      display: block;
      margin-bottom: 5px;
    }

    input[type=&quot;number&quot;] {
      width: 100%;
      padding: 5px;
    }
  `);

  // 主界面
  const createUI = () =&amp;gt; {
    const wrapper = document.createElement(&apos;div&apos;);
    wrapper.className = &apos;gif-converter-wrapper&apos;;

    const triggerBtn = document.createElement(&apos;button&apos;);
    triggerBtn.id = &apos;gifTriggerBtn&apos;;
    triggerBtn.textContent = &apos;🎬 创建 GIF&apos;;

    const panel = document.createElement(&apos;div&apos;);
    panel.className = &apos;config-panel&apos;;
    panel.style.display = &apos;none&apos;;

    wrapper.append(triggerBtn, panel);
    document.body.appendChild(wrapper);

    return { triggerBtn, panel };
  };

  // 配置表单
  const createConfigForm = (video, panel) =&amp;gt; {
    panel.innerHTML = `
      &amp;lt;div class=&quot;config-row&quot;&amp;gt;
        &amp;lt;label&amp;gt;开始时间（秒）:&amp;lt;/label&amp;gt;
        &amp;lt;input type=&quot;number&quot; id=&quot;startTime&quot; step=&quot;0.1&quot; value=&quot;${video.currentTime}&quot; min=&quot;0&quot; max=&quot;${video.duration}&quot;&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div class=&quot;config-row&quot;&amp;gt;
        &amp;lt;label&amp;gt;GIF 长度（秒）:&amp;lt;/label&amp;gt;
        &amp;lt;input type=&quot;number&quot; id=&quot;gifDuration&quot; step=&quot;0.1&quot; value=&quot;5&quot; min=&quot;0&quot; max=&quot;${video.duration - video.currentTime}&quot;&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div class=&quot;config-row&quot;&amp;gt;
        &amp;lt;label&amp;gt;FPS (10-30):&amp;lt;/label&amp;gt;
        &amp;lt;input type=&quot;number&quot; id=&quot;fps&quot; value=&quot;15&quot; min=&quot;10&quot; max=&quot;30&quot;&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div class=&quot;config-row&quot;&amp;gt;
        &amp;lt;label&amp;gt;宽度 (像素):&amp;lt;/label&amp;gt;
        &amp;lt;input type=&quot;number&quot; id=&quot;width&quot; value=&quot;${video.clientWidth}&quot; min=&quot;1&quot; max=&quot;${video.videoWidth}&quot;&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div class=&quot;config-row&quot;&amp;gt;
        &amp;lt;label&amp;gt;高度 (像素):&amp;lt;/label&amp;gt;
        &amp;lt;input type=&quot;number&quot; id=&quot;height&quot; value=&quot;${video.clientHeight}&quot; min=&quot;1&quot; max=&quot;${video.videoHeight}&quot;&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;button id=&quot;generateBtn&quot; style=&quot;margin-top:15px;width:100%&quot;&amp;gt;生成 GIF&amp;lt;/button&amp;gt;
    `;
  };

  // 生成 GIF
  const generateGIF = async (video, config) =&amp;gt; {
    console.debug(config)
    const gif = new GIF({
      workers: 2,
      quality: 10,
      width: config.width,
      height: config.height,
      workerScript: GM_getResourceURL(&apos;gifWorker&apos;)
    });

    const frameDelay = 1000 / config.fps;
    let currentTime = parseFloat(config.startTime);
    let endTime = currentTime + parseFloat(config.gifDuration)
    const canvas = document.createElement(&apos;canvas&apos;);
    canvas.width = config.width;
    canvas.height = config.height;
    const ctx = canvas.getContext(&apos;2d&apos;);
    ctx.imageSmoothingEnabled = true;   // 启用抗锯齿
    ctx.imageSmoothingQuality = &apos;high&apos;; // 设置插值质量为高

    while (currentTime &amp;lt;= endTime) {
      video.currentTime = currentTime;
      await new Promise(resolve =&amp;gt; {
        video.onseeked = () =&amp;gt; {
          ctx.drawImage(video, 0, 0, config.width, config.height);
          const imageData = ctx.getImageData(0, 0, config.width, config.height);
          gif.addFrame(imageData, { delay: frameDelay });
          resolve();
        };
      });
      currentTime += (1 / config.fps);
    }

    gif.on(&apos;finished&apos;, blob =&amp;gt; {
      const url = URL.createObjectURL(blob);
      GM_download(url, `video-clip-${Date.now()}.gif`);
    });

    gif.render();
  };

  // 主逻辑
  const main = () =&amp;gt; {
    const { triggerBtn, panel } = createUI();

    triggerBtn.addEventListener(&apos;click&apos;, () =&amp;gt; {
      const videos = document.querySelectorAll(&apos;video&apos;);
      if (videos.length === 0) {
        console.debug(&apos;没找到视频&apos;);
        return;
      }

      const video = videos[0]; // 默认选择第一个视频
      panel.style.display = panel.style.display === &apos;none&apos; ? &apos;block&apos; : &apos;none&apos;;
      createConfigForm(video, panel);
    });

    panel.addEventListener(&apos;click&apos;, async (e) =&amp;gt; {
      if (e.target.id === &apos;generateBtn&apos;) {
        const videos = document.querySelectorAll(&apos;video&apos;);
        if (videos.length === 0) {
          console.debug(&apos;No video elements found!&apos;);
          return;
        }
        const video = videos[0]; // 默认选择第一个视频

        const config = {
          startTime: parseFloat(panel.querySelector(&apos;#startTime&apos;).value),
          gifDuration: parseFloat(panel.querySelector(&apos;#gifDuration&apos;).value),
          fps: parseInt(panel.querySelector(&apos;#fps&apos;).value),
          width: parseInt(panel.querySelector(&apos;#width&apos;).value),
          height: parseInt(panel.querySelector(&apos;#height&apos;).value),
        };

        panel.style.display = &apos;none&apos;;
        await generateGIF(video, config);
      }
    });
  };

  // 等待页面加载完成后初始化
  window.addEventListener(&apos;load&apos;, main, false);
})();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这部分代码主要是 DeepSeek 完成的，刚开始的版本在 B 站不适用，后面我稍微改了一下，因为 B 站的 video element 不是直接写在 html 中，而是通过脚本去初始化的，所以在 &lt;code&gt;document-end&lt;/code&gt; 的时候不一定能找到。&lt;/p&gt;
&lt;p&gt;接着是创建 canvas，DeepSeek 直接在每一次 while 循环中都创建了一个 canvas，我一开始还觉得他这样做效率很低，于是把 cavans 的创建提出去了，但这样修改出问题了。&lt;/p&gt;
&lt;p&gt;问题的表现是 gif 变成了静态图片，只有最后一帧的图像，后面我去查了一下 gif.js 的源码，发现他每一帧都是一个对象，对象里只保存引用，而不是具体的图像数据，只有在 &lt;code&gt;render&lt;/code&gt; 的时候才去收集具体图像，所以提出 canvas 后也导致了所有帧对象指向的都是同一个 canvas，在 &lt;code&gt;render&lt;/code&gt; 的那一刻，canvas 的图案就是最后一帧的。&lt;/p&gt;
&lt;p&gt;我不知道这一段是 DeepSeek 误打误撞写出来的，还是他真考虑到了 gif.js 存在的问题，有点小震惊。&lt;/p&gt;
</content:encoded></item><item><title>为什么不用 HashNode 了</title><link>https://blog.erio.work/posts/%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8D%E7%94%A8hashnode%E4%BA%86/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8D%E7%94%A8hashnode%E4%BA%86/</guid><description>使用了一年 HashNode 我遇到的问题，以及我选择放弃的原因</description><pubDate>Thu, 20 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我的内容存了两份，一份在本地的 Obsidian 中，一份放在 HashNode，HashNode 就是一个很纯粹的博客服务，类似于一个&lt;strong&gt;公共&lt;/strong&gt;的 WordPress，SEO 这些都帮你做好了，你提供内容就好。&lt;/p&gt;
&lt;p&gt;但他有一些问题吧，比如用户对页面的控制权不多，大家都是一样的样式，不是说这样不好，而是我不太喜欢他的布局，但又调整不了。&lt;/p&gt;
&lt;p&gt;另外他本身对中文内容的支持不太好，比方说在生成 url slug 的时候会&lt;strong&gt;自动忽略中文&lt;/strong&gt;，另外，他默认标题都是英文的，所以会&lt;strong&gt;强制对第一个英文字符进行大写化&lt;/strong&gt;，即便这个英文单词不在标题开头。&lt;/p&gt;
&lt;p&gt;标签方面，因为他是一个公共的博客平台，所以他收集了所有人的标签，像是社交平台的“话题”一样，你也可以新建一个，这个没啥问题，但在博客里面，你&lt;strong&gt;只能通过文章页面最下方的标签按钮进入当前站点的标签分类页面&lt;/strong&gt;，是的，你在博客首页是点不进去的。&lt;/p&gt;
&lt;p&gt;这个标签不区分大小写，所以除非标签的创建者他比较注意规范，否则他要注册了一个大小写不规范的标签，你要么用他，要么你自己注册一个不重名的。&lt;/p&gt;
&lt;p&gt;对于内容的爬取，他虽然提供了相关 API，但还是刚刚说的问题，对中文支持不好，他&lt;strong&gt;只能爬取到内容中的英文部分&lt;/strong&gt;，中文字符会被忽略（我挺好奇他这个爬取功能是怎么实现的，感觉是额外实现的过滤）。&lt;/p&gt;
</content:encoded></item><item><title>将智能插座通过 HomeKit 接入 HomeAssistant</title><link>https://blog.erio.work/posts/%E5%B0%86%E6%99%BA%E8%83%BD%E6%8F%92%E5%BA%A7%E9%80%9A%E8%BF%87homekit%E6%8E%A5%E5%85%A5homeassistant/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E5%B0%86%E6%99%BA%E8%83%BD%E6%8F%92%E5%BA%A7%E9%80%9A%E8%BF%87homekit%E6%8E%A5%E5%85%A5homeassistant/</guid><description>Cozylife 的智能插座无法直接通过断开 HomeKit 的方式接入 Home Assistant，但可以通过其他方式实现</description><pubDate>Sat, 30 Nov 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;近期在淘宝上买了个 Homekit + Cozylife 的插座，就这种：
&lt;img src=&quot;20241130212735.png&quot; alt=&quot;1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;一开始只通过 iOS 访问，就是只连接 Homekit，长按开关重置插座，iOS 一扫码就连上了，后来我嫌在外面访问不了，又不想掏钱买苹果的 HomePod，于是就装上了 Home Assistant，打算让设备们都连上 HA，这样就不用交苹果税了。&lt;/p&gt;
&lt;p&gt;连接方式还是通过 Homekit，一般来说支持 Homekit 设备都能这样连接，先连上 iOS，然后在 Home App 中移除设备，这时候就能在 HA 中找到设备：
&lt;img src=&quot;20241130213051.png&quot; alt=&quot;1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;点击“配置” HA 就能自动连接上，但这时候就出现了问题，这插座怎么都&lt;strong&gt;连不上，也不报错&lt;/strong&gt;，就一直 loading，直到设备退出搜索状态。。。&lt;/p&gt;
&lt;p&gt;思考再三，决定试试 Cozylife 的连接方案，这个 HA 并没有原生支持，得装插件提供额外的集成，这里提醒一句，不要安装 Cozylife 官方的，那个插件很差，可以安装&lt;a href=&quot;https://github.com/yangqian/hass-cozylife&quot;&gt;第三方&lt;/a&gt;的。&lt;/p&gt;
&lt;p&gt;说实话我觉得这个集成的使用方式也挺&lt;strong&gt;麻烦&lt;/strong&gt;的，先得搜索局域网的设备信息，再把信息粘贴进 HA 的配置里，没有那种图形界面或者一键操作，我现在在租房里，这里的路由器没有提供 DHCP 功能，所以我固定不了设备的地址啊！&lt;/p&gt;
&lt;p&gt;这时候我也不知道我咋想的，就下了一个 Cozylife 的 App：
&lt;img src=&quot;589c28a12faf3c819cad5708d753251.jpg&quot; alt=&quot;2&quot; /&gt;
连接上之后，神奇的事来了，HA 里居然通过 Homekit 搜到了这个设备：
&lt;img src=&quot;20241130214248.png&quot; alt=&quot;1&quot; /&gt;
等于说设备连上 Cozylife 之后，自动暴露出了 Homekit “接口”，这时 HA 连接是正常的，不会出现无限 loading 的情况。&lt;/p&gt;
&lt;p&gt;接入 HA 之后一切都很顺利，也没有出现控制不到、掉线的情况。&lt;/p&gt;
</content:encoded></item><item><title>慎用 Tauri 读取文件</title><link>https://blog.erio.work/posts/%E6%85%8E%E7%94%A8tauri%E8%AF%BB%E5%8F%96%E6%96%87%E4%BB%B6/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E6%85%8E%E7%94%A8tauri%E8%AF%BB%E5%8F%96%E6%96%87%E4%BB%B6/</guid><description>Tauri 读取文件可能导致内存溢出</description><pubDate>Fri, 15 Nov 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;TL;DR&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;一句话，要么升级 v2，要么&lt;s&gt;憋着&lt;/s&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这篇文章真的是加急写出来的，因为 Tauri v1 的 &lt;code&gt;readBinaryFile&lt;/code&gt; API 太蠢了，我受不了了。&lt;/p&gt;
&lt;h1&gt;问题&lt;/h1&gt;
&lt;p&gt;这个 API 会有内存泄漏的问题，而且 v1 版本是&lt;strong&gt;避免不了&lt;/strong&gt;的，举个例子就是读取 400M 左右的文件，进程会吃 18G 左右，读取完成后再慢慢清理掉。&lt;/p&gt;
&lt;p&gt;![[b78d8b48-9f57-47b2-b2fe-719a222390f2.png]]&lt;/p&gt;
&lt;p&gt;![[983ffd97-3bf8-4f9c-84bd-4bd4db0b3ccb.png]]&lt;/p&gt;
&lt;p&gt;在做测试的过程中，我一开始直接读文件，读一半因为这个问题，程序崩溃了两回，后面我调整了写法才避免了崩溃，但吃内存的问题还是无法解决。&lt;/p&gt;
&lt;p&gt;我查了 &lt;a href=&quot;https://github.com/tauri-apps/tauri/issues/9190&quot;&gt;issue&lt;/a&gt; 才发现，这个问题是序列化导致的，Tauri 的 JS API 会将一切 &lt;strong&gt;Rust 端的数据序列化成 JSON&lt;/strong&gt; 然后传回前端，这也包括了文件数据，我估计他是把文件同步读完，然后再开辟一份新内存区域去做转换，所以浪费了那么多空间。&lt;/p&gt;
&lt;p&gt;速度慢的问题在开发环境特别明显，在生产环境会好不少。&lt;/p&gt;
&lt;h1&gt;解法&lt;/h1&gt;
&lt;p&gt;根据 issue 下面所说，如果你想&lt;strong&gt;彻底解决&lt;/strong&gt;这个问题，要么升级 v2 后使用 &lt;code&gt;tauri::ipc::Response&lt;/code&gt; 包装 &lt;code&gt;Vec&lt;/code&gt;，要么自己做一个协议去交换数据，不依赖 Tauri 提供的 NPM 包。&lt;/p&gt;
&lt;p&gt;但 v1 升级到 v2 肯定又是一场恶战，我目前在 v1 的办法也只能&lt;strong&gt;暂缓&lt;/strong&gt;爆内存的问题，就是&lt;strong&gt;限制并发量&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const runConcurrency = async &amp;lt;T&amp;gt;(
  tasks: Array&amp;lt;() =&amp;gt; Promise&amp;lt;T&amp;gt;&amp;gt;,
  concurrency: number = 3
): Promise&amp;lt;Array&amp;lt;T | Error&amp;gt;&amp;gt; =&amp;gt; {
  const results: Array&amp;lt;T | Error&amp;gt; = new Array(tasks.length);
  let currentIndex = 0;

  // 创建工作线程函数
  const worker = async () =&amp;gt; {
    while (currentIndex &amp;lt; tasks.length) {
      const taskIndex = currentIndex++;
      const task = tasks[taskIndex];
      try {
        const result = await task();
        results[taskIndex] = result;
      } catch (err) {
        results[taskIndex] = err as Error;
      }
    }
  };

  // 创建指定数量的工作线程
  const workers = Array(Math.min(concurrency, tasks.length))
    .fill(null)
    .map(() =&amp;gt; worker());

  // 等待所有工作线程完成
  await Promise.all(workers);
  return results;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大概是这样，限制同时读取的文件数量，单个文件的读取只能听天由命了。&lt;/p&gt;
&lt;h1&gt;其他解法（大概）&lt;/h1&gt;
&lt;p&gt;Claude 的方案是自己做一个 Tauri command 去读取文件，我也试过了，效果不明显，毕竟 command 的数据交换也是要通过 JS API 的，那也会序列化成 JSON：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;use tauri::plugin::{Builder, TauriPlugin};  
use tauri::{command, generate_handler, Wry};  
use tokio::fs;  
use anyhow::Result;  
  
#[command]  
async fn read_file_binary(path: String) -&amp;gt; Result&amp;lt;Vec&amp;lt;u8&amp;gt;, String&amp;gt; {  
    match fs::read(&amp;amp;path).await {  
        Ok(data) =&amp;gt; Ok(data),  
        Err(e) =&amp;gt; Err(e.to_string())  
    }  
}  
  
pub fn init() -&amp;gt; TauriPlugin&amp;lt;Wry&amp;gt; {  
    Builder::new(&quot;file&quot;)  
        .invoke_handler(generate_handler![  
            read_file_binary,  
        ])  
        .build()  
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;参考&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;https://github.com/tauri-apps/tauri/issues/1817&lt;/li&gt;
&lt;li&gt;https://github.com/tauri-apps/tauri/issues/9190&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>触底分页加载模版代码</title><link>https://blog.erio.work/posts/%E8%A7%A6%E5%BA%95%E5%88%86%E9%A1%B5%E5%8A%A0%E8%BD%BD%E6%A8%A1%E7%89%88%E4%BB%A3%E7%A0%81/</link><guid isPermaLink="true">https://blog.erio.work/posts/%E8%A7%A6%E5%BA%95%E5%88%86%E9%A1%B5%E5%8A%A0%E8%BD%BD%E6%A8%A1%E7%89%88%E4%BB%A3%E7%A0%81/</guid><description>基于 React Hooks 的触底加载模版代码</description><pubDate>Fri, 27 Mar 2020 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;基本状态&lt;/h1&gt;
&lt;p&gt;基本的状态可以分成 5 个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const [data, setData] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;data&lt;/code&gt; 储存列表数据，&lt;code&gt;page&lt;/code&gt; 是当前页码，&lt;code&gt;hasMore&lt;/code&gt; 表示已经结束，&lt;code&gt;isLoading&lt;/code&gt; 是加载中的状态，得和 &lt;code&gt;isRefreshing&lt;/code&gt; 刷新中区分开来，原因后面会讲。&lt;/p&gt;
&lt;h1&gt;触底条件&lt;/h1&gt;
&lt;h2&gt;尺寸计算&lt;/h2&gt;
&lt;p&gt;触底判断可以分成两种，兼容性更好的方案是通过尺寸属性去计算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;scrollHeight - (scrollTop + clientHeight) &amp;lt; threshold
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个属性的意义如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;scrollHeight&lt;/code&gt; 是容器的总高度，包括不可见的滚动区域&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scrollTop&lt;/code&gt; 是已经滚动完成的高度，也就是滚出去的上半部分高度&lt;/li&gt;
&lt;li&gt;&lt;code&gt;clientHeight&lt;/code&gt; 容器自身高度（包括 padding）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以计算下来 &lt;code&gt;scrollHeight - (scrollTop + clientHeight)&lt;/code&gt; 指的是容器还没有滚动到的剩余高度（下方不可见部分），当他小于阈值 &lt;code&gt;threshold&lt;/code&gt; 时触发加载。&lt;/p&gt;
&lt;h2&gt;observer&lt;/h2&gt;
&lt;p&gt;性能更好的方案，不会频繁触发布局：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const observer = useRef();
  const sentinelRef = useCallback(node =&amp;gt; {
    if (observer.current) observer.current.disconnect();
    
    observer.current = new IntersectionObserver(entries =&amp;gt; {
      if (entries[0].isIntersecting &amp;amp;&amp;amp; hasMore &amp;amp;&amp;amp; !isLoading &amp;amp;&amp;amp; !isRefreshing) {
        loadMoreData();
      }
    }, { rootMargin: &apos;100px&apos; });

    node &amp;amp;&amp;amp; observer.current.observe(node);
  }, [hasMore, isLoading, isRefreshing]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时在列表最下方加入探针元素：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div ref={sentinelRef} style={{ height: 1 }} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在探针滚动到可见的时候，触发 &lt;code&gt;observer&lt;/code&gt;，判断当前&lt;strong&gt;终止条件&lt;/strong&gt;和&lt;strong&gt;加载中&lt;/strong&gt;，避免多次触发。&lt;/p&gt;
&lt;h1&gt;加载更多&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;const loadData = async (targetPage = page, isRefresh = false) =&amp;gt; {
    try {
      isRefresh ? setIsRefreshing(true) : setIsLoading(true);
      
      const newData = await fetchData(targetPage);
      
      setData(prev =&amp;gt; 
        targetPage === 1 ? newData : [...prev, ...newData]
      );
      setHasMore(newData.length &amp;gt;= 10); // 假设每页10条
      setPage(targetPage + 1);
    } finally {
      isRefresh ? setIsRefreshing(false) : setIsLoading(false);
    }
  };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;加载后的列表数据拼接到 &lt;code&gt;data&lt;/code&gt; 之后，&lt;code&gt;setHasMore&lt;/code&gt; 判断是否终止，&lt;code&gt;setPage&lt;/code&gt; 设置新页码。&lt;/p&gt;
&lt;h1&gt;刷新&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;const handleRefresh = () =&amp;gt; {
	if (isRefreshing) return;
	setPage(1);          // 重置页码
	setHasMore(true);    // 重置数据终止标记
	loadData(1, true);   // 强制从第一页加载
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前面提到的 &lt;code&gt;isLoading&lt;/code&gt; 和 &lt;code&gt;isRefreshing&lt;/code&gt; 就是为了区分 &lt;code&gt;loadMore&lt;/code&gt; 和 &lt;code&gt;refresh&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;为什么不复用 isLoading，原因有几个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;要区分数据的赋值逻辑，&lt;code&gt;loadMore&lt;/code&gt; 是拼接，&lt;code&gt;refresh&lt;/code&gt; 是重设&lt;/li&gt;
&lt;li&gt;单状态管理会导致竞态条件，刷新和加载都共同维护 &lt;code&gt;isLoading&lt;/code&gt;，会增加复杂度，很可能出现双方通过异步先后修改 &lt;code&gt;isLoading&lt;/code&gt; 的情况&lt;/li&gt;
&lt;li&gt;能显示不同的提示语&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;初始化&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;useEffect(() =&amp;gt; {
	loadData();
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;额外功能&lt;/h1&gt;
&lt;h2&gt;请求取消&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;const abortController = useRef(new AbortController());

useEffect(() =&amp;gt; {
  return () =&amp;gt; abortController.current.abort();
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;刷新防抖&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;const lastRefresh = useRef(0);

const handleRefresh = () =&amp;gt; {
  if (Date.now() - lastRefresh.current &amp;lt; 3000) return;
  lastRefresh.current = Date.now();
  // ...刷新逻辑
};
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item></channel></rss>