<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Yuer6327 Blog</title><description>个人博客 - 项目开发与设计笔记</description><link>https://blog.yuer6327.top/</link><language>zh_CN</language><item><title>记高中三年最后一餐盒饭</title><link>https://blog.yuer6327.top/posts/%E8%AE%B0%E9%AB%98%E4%B8%AD%E4%B8%89%E5%B9%B4%E6%9C%80%E5%90%8E%E4%B8%80%E9%A4%90%E7%9B%92%E9%A5%AD/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/%E8%AE%B0%E9%AB%98%E4%B8%AD%E4%B8%89%E5%B9%B4%E6%9C%80%E5%90%8E%E4%B8%80%E9%A4%90%E7%9B%92%E9%A5%AD/</guid><description>干饭有感......</description><pubDate>Sat, 30 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;/assets/images/1.jpg&quot; alt=&quot;记高中三年最后一餐盒饭&quot; /&gt;&lt;/p&gt;&lt;h2&gt;记高中三年最后一餐盒饭&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;&amp;lt;font color=&quot;#595959&quot;&amp;gt;鄙人高二，只是高三待遇好点不吃盒饭了&amp;lt;/font&amp;gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;即使是最后一次，好像跟之前的无数次并没有什么不一样的&lt;/p&gt;
&lt;p&gt;想到了要珍惜，想到了值得怀念，但嘴还是很诚实的没有做到光盘&lt;/p&gt;
&lt;p&gt;总是想怀念些什么，但作为17岁的我们又有什么是真的值得怀念的呢？&lt;/p&gt;
&lt;p&gt;人类能回忆起的最早记忆平均可以追溯到两岁半，大脑在20多岁时达到巅峰状态，然后开始逐渐衰退。&lt;/p&gt;
&lt;p&gt;而作为“巅峰”时期的我们，却天天囿于寻找意义之中，意义何在呢？&lt;/p&gt;
</content:encoded></item><item><title>Fuwari「稍后再读」功能</title><link>https://blog.yuer6327.top/posts/fuwari%E7%A8%8D%E5%90%8E%E5%86%8D%E8%AF%BB%E5%8A%9F%E8%83%BD/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/fuwari%E7%A8%8D%E5%90%8E%E5%86%8D%E8%AF%BB%E5%8A%9F%E8%83%BD/</guid><description>使用 localStorage + CSS Transition 为 Fuwari 静态博客实现文章收藏与自由落体动画的完整过程</description><pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在文章页面添加「稍后再读」书签按钮，点击后文章内容自由落体掉出屏幕、侧栏列表静默刷新，同时支持在侧栏点击已收藏文章直接跳转。纯前端 localStorage 存储，无后端依赖。&lt;/p&gt;
&lt;p&gt;:::important
Fuwari 使用 &lt;strong&gt;Swup 4&lt;/strong&gt; 做页面切换，侧栏（包括「稍后再读」widget）位于 &lt;code&gt;#swup-container&lt;/code&gt; &lt;strong&gt;外部&lt;/strong&gt;，不受 Swup DOM 替换影响。所有交互脚本必须使用 &lt;code&gt;&amp;lt;script is:inline&amp;gt;&lt;/code&gt; 以保证在 Swup 切页后仍可执行。
:::&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;一、数据结构&lt;/h1&gt;
&lt;p&gt;使用 &lt;code&gt;localStorage&lt;/code&gt; 存储，key 为 &lt;code&gt;readLater&lt;/code&gt;，值为 JSON 数组，上限 20 条，最新添加的排在最前：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
  {
    &quot;slug&quot;: &quot;post-slug&quot;,
    &quot;title&quot;: &quot;文章标题&quot;,
    &quot;url&quot;: &quot;/posts/post-slug/&quot;,
    &quot;addedAt&quot;: 1716950000000
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;slug&lt;/code&gt;：文章标识，用于去重判断&lt;/li&gt;
&lt;li&gt;&lt;code&gt;url&lt;/code&gt;：相对路径（&lt;code&gt;/posts/xxx/&lt;/code&gt;），兼容 Swup 的 &lt;code&gt;navigate()&lt;/code&gt; 方法&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addedAt&lt;/code&gt;：&lt;code&gt;Date.now()&lt;/code&gt; 时间戳，用于显示&quot;5分钟前&quot;等相对时间&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;二、i18n 配置&lt;/h1&gt;
&lt;h2&gt;2.1 新增枚举&lt;/h2&gt;
&lt;p&gt;编辑 &lt;code&gt;src/i18n/i18nKey.ts&lt;/code&gt;，在枚举末尾追加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;readLater = &quot;readLater&quot;,
readLaterAdd = &quot;readLaterAdd&quot;,
readLaterRemove = &quot;readLaterRemove&quot;,
readLaterEmpty = &quot;readLaterEmpty&quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.2 各语言翻译&lt;/h2&gt;
&lt;p&gt;编辑 &lt;code&gt;src/i18n/languages/*.ts&lt;/code&gt;（共 9 个文件），在 translation 对象中追加对应的 key-value。以 &lt;code&gt;zh_CN.ts&lt;/code&gt; 和 &lt;code&gt;en.ts&lt;/code&gt; 为例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// zh_CN.ts
[I18nKey.readLater]: &quot;稍后再读&quot;,
[I18nKey.readLaterAdd]: &quot;加入稍后再读&quot;,
[I18nKey.readLaterRemove]: &quot;移除稍后再读&quot;,
[I18nKey.readLaterEmpty]: &quot;暂无文章&quot;,

// en.ts
[I18nKey.readLater]: &quot;Read Later&quot;,
[I18nKey.readLaterAdd]: &quot;Add to Read Later&quot;,
[I18nKey.readLaterRemove]: &quot;Remove from Read Later&quot;,
[I18nKey.readLaterEmpty]: &quot;No saved articles&quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.3 类型声明&lt;/h2&gt;
&lt;p&gt;编辑 &lt;code&gt;src/global.d.ts&lt;/code&gt;，在 &lt;code&gt;Window&lt;/code&gt; 接口中添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ReadLaterStore?: {
    getList: () =&amp;gt; Array&amp;lt;{slug: string; title: string; url: string; addedAt: number}&amp;gt;;
    saveList: (list: Array&amp;lt;{slug: string; title: string; url: string; addedAt: number}&amp;gt;) =&amp;gt; void;
    has: (slug: string) =&amp;gt; boolean;
    add: (item: {slug: string; title: string; url: string}) =&amp;gt; void;
    remove: (slug: string) =&amp;gt; void;
    renderButton: () =&amp;gt; void;
    renderSidebar: () =&amp;gt; void;
    pulseReadLaterCard: () =&amp;gt; void;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;三、ReadLaterButton 组件&lt;/h1&gt;
&lt;p&gt;新建 &lt;code&gt;src/components/ReadLaterButton.astro&lt;/code&gt;，放在文章标题右侧，与 ShareButton 并列。&lt;/p&gt;
&lt;h2&gt;3.1 模板&lt;/h2&gt;
&lt;p&gt;两个图标切换显示：未收藏时显示 &lt;code&gt;bookmark-outline&lt;/code&gt;，已收藏时显示实心 &lt;code&gt;bookmark&lt;/code&gt;，通过 CSS 类 &lt;code&gt;.saved&lt;/code&gt; 控制显隐。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import { Icon } from &quot;astro-icon/components&quot;;

interface Props {
    slug: string;
    title: string;
    url: string;
    class?: string;
}
const { slug, title, url } = Astro.props;
const className = Astro.props.class || &quot;&quot;;
---

&amp;lt;div class:list={[&quot;readlater-container inline-flex items-center&quot;, className]}&amp;gt;
    &amp;lt;button
        id=&quot;read-later-btn&quot;
        class=&quot;readlater-btn btn-plain rounded-lg h-9 w-9 flex items-center justify-center&quot;
        data-slug={slug}
        data-title={title}
        data-url={url}
        aria-label=&quot;稍后再读&quot;
    &amp;gt;
        &amp;lt;Icon name=&quot;material-symbols:bookmark-outline-rounded&quot; class=&quot;readlater-icon text-[1.1rem]&quot; /&amp;gt;
        &amp;lt;Icon name=&quot;material-symbols:bookmark-rounded&quot; class=&quot;readlater-active-icon text-[1.1rem]&quot; /&amp;gt;
    &amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.2 样式&lt;/h2&gt;
&lt;p&gt;使用 &lt;code&gt;&amp;lt;style is:inline&amp;gt;&lt;/code&gt;，因为按钮的 &lt;code&gt;.saved&lt;/code&gt; 类由 JS 动态添加，Astro 的 scoped style 无法匹配。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.readlater-container { display: inline-flex; align-items: center; }
.readlater-btn .readlater-icon { display: inline; }
.readlater-btn .readlater-active-icon { display: none; }
.readlater-btn.saved .readlater-icon { display: none; }
.readlater-btn.saved .readlater-active-icon { display: inline; }
.readlater-btn.saved { color: var(--primary) !important; }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.3 ReadLaterStore 全局对象&lt;/h2&gt;
&lt;p&gt;整个功能的核心是 &lt;code&gt;window.ReadLaterStore&lt;/code&gt;，一个挂载在 &lt;code&gt;window&lt;/code&gt; 上的全局对象，被按钮组件和侧栏 widget 共享。使用 &lt;code&gt;if (!window.ReadLaterStore)&lt;/code&gt; 守卫，确保无论哪个组件先加载，都只初始化一次。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (!window.ReadLaterStore) {
    var STORE_KEY = &apos;readLater&apos;;
    var MAX_ITEMS = 20;

    window.ReadLaterStore = {
        getList: function() {
            try { return JSON.parse(localStorage.getItem(STORE_KEY)) || []; }
            catch(e) { return []; }
        },
        saveList: function(list) {
            try {
                if (list.length &amp;gt; MAX_ITEMS) list = list.slice(0, MAX_ITEMS);
                localStorage.setItem(STORE_KEY, JSON.stringify(list));
            } catch(e) {}
        },
        has: function(slug) {
            return this.getList().some(function(i) { return i.slug === slug; });
        },
        add: function(item) {
            // 先过滤掉同 slug 条目，再插入最前面，天然去重且保留最新
            var list = this.getList().filter(function(i) { return i.slug !== item.slug; });
            list.unshift({
                slug: item.slug, title: item.title,
                url: item.url, addedAt: Date.now()
            });
            this.saveList(list);
        },
        remove: function(slug) {
            var list = this.getList().filter(function(i) { return i.slug !== slug; });
            this.saveList(list);
        },
        // ... renderButton / renderSidebar / pulseReadLaterCard
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;renderButton()&lt;/code&gt; 同步按钮的视觉状态（检查 localStorage 中是否有当前文章 slug），&lt;code&gt;renderSidebar()&lt;/code&gt; 重绘侧栏列表 HTML，两者均在 &lt;code&gt;add/remove&lt;/code&gt; 后立即调用。&lt;/p&gt;
&lt;h2&gt;3.4 自由落体动画&lt;/h2&gt;
&lt;p&gt;这是实现中最复杂的部分。需求是：点击收藏后，文章内容向下掉落并消失，同时露出首页内容，&lt;strong&gt;整个过程没有页面刷新感&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;克隆策略&lt;/h3&gt;
&lt;p&gt;核心矛盾：如果直接隐藏原始内容（&lt;code&gt;visibility: hidden&lt;/code&gt;），内容区会变空白，看起来像&quot;刷新&quot;。&lt;/p&gt;
&lt;p&gt;解决方案：&lt;strong&gt;不隐藏原内容&lt;/strong&gt;，克隆 &lt;code&gt;#content-wrapper&lt;/code&gt; 并用 &lt;code&gt;position: fixed&lt;/code&gt; + &lt;code&gt;z-index: 60&lt;/code&gt; 覆盖在原内容上方。原内容始终可见，被克隆体遮住，用户无感。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function prepareFallClone() {
    var contentEl = document.getElementById(&apos;content-wrapper&apos;);
    if (!contentEl) return null;
    var rect = contentEl.getBoundingClientRect();

    var clone = contentEl.cloneNode(true);
    clone.removeAttribute(&apos;id&apos;);
    clone.style.cssText =
        &apos;position:fixed;left:&apos; + rect.left + &apos;px;top:&apos; + rect.top + &apos;px;&apos; +
        &apos;width:&apos; + rect.width + &apos;px;height:&apos; + rect.height + &apos;px;&apos; +
        &apos;z-index:60;margin:0;overflow:hidden;pointer-events:none;&apos; +
        &apos;will-change:transform,opacity;transform-origin:center center;&apos; +
        &apos;opacity:1;animation:none;&apos;;  // ← 关键：覆盖 onload-animation 的 opacity:0
    document.body.appendChild(clone);
    // 不隐藏原内容 —— 克隆体遮着，无空白闪烁
    return { clone: clone, contentEl: contentEl };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::caution[必须设置 &lt;code&gt;animation:none&lt;/code&gt;]
&lt;code&gt;#content-wrapper&lt;/code&gt; 带有 &lt;code&gt;onload-animation&lt;/code&gt; 类（&lt;code&gt;opacity: 0; animation: 300ms fade-in-up&lt;/code&gt;）。克隆体会继承这个动画并从头播放，导致克隆体初始 &lt;code&gt;opacity: 0&lt;/code&gt; 而&lt;strong&gt;完全不可见&lt;/strong&gt;。必须在 inline style 中显式设置 &lt;code&gt;opacity:1;animation:none;&lt;/code&gt; 覆盖。
:::&lt;/p&gt;
&lt;h3&gt;动画与 Fetch 并行&lt;/h3&gt;
&lt;p&gt;动画和首页数据获取同时进行。两个异步任务通过 &lt;code&gt;cloneDone&lt;/code&gt; 和 &lt;code&gt;fetchDone&lt;/code&gt; 两个布尔标志协调，只有两者都完成时才执行 &lt;code&gt;reveal()&lt;/code&gt; 替换内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function animateFallClone(state, callback) {
    var clone = state.clone;
    var cloneDone = false, fetchDone = false, newContentEl = null;

    function reveal() {
        if (!cloneDone || !fetchDone) return;  // 两者都就绪才执行
        if (clone.parentNode) clone.remove();
        if (newContentEl) {
            newContentEl.style.opacity = &apos;1&apos;;   // 瞬间显示，无渐显
            newContentEl.style.transition = &apos;&apos;;
        }
        window.scrollTo(0, 0);
        if (window.ReadLaterStore) window.ReadLaterStore.renderSidebar();
        callback();
    }

    // CSS Transition: 自由落体
    requestAnimationFrame(function() {
        requestAnimationFrame(function() {
            clone.style.transition = &apos;transform 1s cubic-bezier(0.4,0,1,1), opacity 1s ease-in&apos;;
            clone.style.transform = &apos;translateY(120vh) scale(0.3)&apos;;
            clone.style.opacity = &apos;0&apos;;
        });
    });

    clone.addEventListener(&apos;transitionend&apos;, function() { cloneDone = true; reveal(); });
    setTimeout(function() { cloneDone = true; reveal(); }, 1500); // 安全超时

    // Fetch 首页 HTML
    fetch(window.location.origin + &apos;/&apos;)
        .then(function(r) { return r.text(); })
        .then(function(html) {
            var doc = new DOMParser().parseFromString(html, &apos;text/html&apos;);
            var newSwup = doc.getElementById(&apos;swup-container&apos;);
            var swupEl = document.getElementById(&apos;swup-container&apos;);
            if (newSwup &amp;amp;&amp;amp; swupEl) {
                swupEl.innerHTML = newSwup.innerHTML;  // 替换内容区
                newContentEl = document.getElementById(&apos;content-wrapper&apos;);
                if (newContentEl) {
                    newContentEl.style.opacity = &apos;0&apos;;   // 先隐藏，等动画结束再显示
                    newContentEl.style.transition = &apos;none&apos;;
                }
            }
            fetchDone = true;
            reveal();
        })
        .catch(function() { window.location.href = &apos;/&apos;; });  // fallback
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::important[为什么用 fetch + innerHTML 而不是 swup.navigate()？]
&lt;code&gt;swup.navigate(&apos;/&apos;)&lt;/code&gt; 会触发 Swup 自己的退出动画（约 300ms），在动画期间内容区 &lt;code&gt;opacity: 0&lt;/code&gt;，用户会看到明显的内容消失再出现。用 &lt;code&gt;fetch&lt;/code&gt; + &lt;code&gt;innerHTML&lt;/code&gt; 直接替换内容，配合 &lt;code&gt;opacity:0 → 1&lt;/code&gt; 瞬间切换，完全跳过 Swup 的动画周期，实现真正的&quot;无刷新&quot;。
:::&lt;/p&gt;
&lt;h3&gt;时序图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;点击书签
├─ prepareFallClone()    → 克隆体创建，覆盖在原内容上方
├─ localStorage.add()    → 写入数据（被克隆体遮住，用户无感）
├─ renderButton()        → 按钮变为已收藏状态
├─ renderSidebar()       → 侧栏列表更新（被克隆体遮住）
│
├─ animateFallClone()
│   ├─ [并行] clone: translateY(120vh) scale(0.3) opacity:0   ← 1秒
│   ├─ [并行] fetch(&apos;/&apos;) → 获取首页 HTML → 替换 #swup-container
│   │                      → newContentEl opacity:0（先隐藏）
│   └─ 两者都完成 → reveal()
│       → 移除克隆体
│       → newContentEl opacity:1（瞬间显示首页）
│       → renderSidebar()
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;四、侧栏 Widget&lt;/h1&gt;
&lt;p&gt;新建 &lt;code&gt;src/components/widget/ReadLater.astro&lt;/code&gt;，使用 Fuwari 原生的 &lt;code&gt;WidgetLayout&lt;/code&gt; 壳子。&lt;/p&gt;
&lt;h2&gt;4.1 模板&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;---
import I18nKey from &quot;../../i18n/i18nKey&quot;;
import { i18n } from &quot;../../i18n/translation&quot;;
import WidgetLayout from &quot;./WidgetLayout.astro&quot;;

interface Props { class?: string; style?: string; }
const className = Astro.props.class;
const style = Astro.props.style;
---

&amp;lt;WidgetLayout name={i18n(I18nKey.readLater)} id=&quot;read-later&quot; class={className} style={style}&amp;gt;
    &amp;lt;div id=&quot;read-later-content&quot;&amp;gt;
        &amp;lt;div id=&quot;read-later-empty&quot; class=&quot;hidden text-sm text-black/50 dark:text-white/50 py-1&quot;&amp;gt;
            {i18n(I18nKey.readLaterEmpty)}
        &amp;lt;/div&amp;gt;
        &amp;lt;div id=&quot;read-later-list&quot; class=&quot;flex flex-col gap-0.5&quot;&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/WidgetLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.2 侧栏列表渲染&lt;/h2&gt;
&lt;p&gt;侧栏组件的 &lt;code&gt;&amp;lt;script is:inline&amp;gt;&lt;/code&gt; 中也包含一个 &lt;code&gt;ReadLaterStore&lt;/code&gt; 定义（带 &lt;code&gt;if (!window.ReadLaterStore)&lt;/code&gt; 守卫），以及独立的 &lt;code&gt;renderReadLaterSidebar()&lt;/code&gt; 函数。&lt;/p&gt;
&lt;p&gt;关键点是事件委托：列表的点击事件绑定在 &lt;code&gt;#read-later-list&lt;/code&gt; 容器上，而不是每个 &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; 标签上。这样无论列表内容如何更新（&lt;code&gt;innerHTML&lt;/code&gt; 重写），点击处理始终有效：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var __readLaterListDelegated = false;

function renderReadLaterSidebar() {
    var listEl = document.getElementById(&apos;read-later-list&apos;);
    var emptyEl = document.getElementById(&apos;read-later-empty&apos;);
    if (!listEl) return;
    var items = window.ReadLaterStore.getList();

    // 事件委托：只绑定一次
    if (!__readLaterListDelegated) {
        __readLaterListDelegated = true;
        listEl.addEventListener(&apos;click&apos;, function(e) {
            var link = e.target.closest(&apos;a[data-slug]&apos;);
            if (!link) return;
            e.preventDefault();
            var slug = link.dataset.slug;
            var url = link.getAttribute(&apos;href&apos;);
            // 先从列表中删除，再跳转
            if (window.ReadLaterStore) {
                window.ReadLaterStore.remove(slug);
                renderReadLaterSidebar();
            }
            if (url) {
                if (window.swup &amp;amp;&amp;amp; window.swup.navigate) {
                    window.swup.navigate(url);  // SPA 跳转，无全量刷新
                } else {
                    window.location.href = url;
                }
            }
        });
    }

    // 渲染列表条目（带 data-slug 属性供事件委托使用）
    if (items.length === 0) {
        listEl.innerHTML = &apos;&apos;;
        if (emptyEl) emptyEl.classList.remove(&apos;hidden&apos;);
        return;
    }
    if (emptyEl) emptyEl.classList.add(&apos;hidden&apos;);
    listEl.innerHTML = items.map(function(item) {
        return &apos;&amp;lt;a href=&quot;&apos; + item.url + &apos;&quot; data-slug=&quot;&apos; + item.slug + &apos;&quot; &apos; +
            &apos;class=&quot;read-later-item btn-plain rounded-lg w-full h-9 px-2 &apos; +
            &apos;flex items-center gap-2 text-sm cursor-pointer ...&quot;&amp;gt;&apos; +
            &apos;&amp;lt;span class=&quot;flex-1 overflow-hidden whitespace-nowrap text-ellipsis&quot;&amp;gt;&apos; +
                item.title.replace(/&amp;lt;/g, &apos;&amp;amp;lt;&apos;) + &apos;&amp;lt;/span&amp;gt;&apos; +
            &apos;&amp;lt;span class=&quot;text-xs text-black/30 dark:text-white/30 shrink-0&quot;&amp;gt;&apos; +
                timeAgo(item.addedAt) + &apos;&amp;lt;/span&amp;gt;&apos; +
            &apos;&amp;lt;/a&amp;gt;&apos;;
    }).join(&apos;&apos;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;侧栏 widget 还注册了 Swup 的 &lt;code&gt;page:view&lt;/code&gt; 钩子，在每次切页后重新渲染列表（此时列表 DOM 已被 Swup 替换为初始空状态）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;window.swup.hooks.on(&apos;page:view&apos;, function() {
    setTimeout(function() { renderReadLaterSidebar(); }, 50);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;五、接入页面&lt;/h1&gt;
&lt;h2&gt;5.1 文章页&lt;/h2&gt;
&lt;p&gt;编辑 &lt;code&gt;src/pages/posts/[...slug].astro&lt;/code&gt;，在 PostMetadata 旁插入 ReadLaterButton：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ReadLaterButton from &quot;../../components/ReadLaterButton.astro&quot;;

// 在 metadata 区域：
&amp;lt;div class=&quot;flex items-start gap-4 mb-5&quot;&amp;gt;
    &amp;lt;PostMetadata class=&quot;flex-1 min-w-0 overflow-hidden&quot; ... /&amp;gt;
    &amp;lt;div class=&quot;flex items-center gap-1 shrink-0&quot;&amp;gt;
        &amp;lt;ReadLaterButton slug={getEntrySlug(entry)} title={entry.data.title} url={Astro.url.pathname} /&amp;gt;
        &amp;lt;ShareButton title={entry.data.title} url={postUrl} /&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;url&lt;/code&gt; 使用 &lt;code&gt;Astro.url.pathname&lt;/code&gt;（相对路径 &lt;code&gt;/posts/xxx/&lt;/code&gt;）而非完整 URL，保证 Swup 的 &lt;code&gt;navigate()&lt;/code&gt; 方法能正确匹配路由。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PostMetadata&lt;/code&gt; 加 &lt;code&gt;overflow-hidden&lt;/code&gt; 确保窄屏时内部内容被裁剪而不是把按钮挤出容器。&lt;/p&gt;
&lt;h2&gt;5.2 侧栏&lt;/h2&gt;
&lt;p&gt;编辑 &lt;code&gt;src/components/widget/SideBar.astro&lt;/code&gt;，在 Weather 和 Tag 之间插入 ReadLater widget：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ReadLater from &quot;./ReadLater.astro&quot;;

&amp;lt;Weather class=&quot;onload-animation&quot; style=&quot;animation-delay: 150ms&quot;&amp;gt;&amp;lt;/Weather&amp;gt;
&amp;lt;ReadLater class=&quot;onload-animation&quot; style=&quot;animation-delay: 200ms&quot;&amp;gt;&amp;lt;/ReadLater&amp;gt;
&amp;lt;Tag class=&quot;onload-animation&quot; style=&quot;animation-delay: 250ms&quot;&amp;gt;&amp;lt;/Tag&amp;gt;
&amp;lt;Categories class=&quot;onload-animation&quot; style=&quot;animation-delay: 300ms&quot;&amp;gt;&amp;lt;/Categories&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;侧栏位于 &lt;code&gt;#swup-container&lt;/code&gt; 外部（Fuwari 的 grid 布局中独占一列），Swup 的 &lt;code&gt;containers: [&quot;#swup-container&quot;, &quot;#toc&quot;]&lt;/code&gt; 配置不包含侧栏，因此 Swup 切页时侧栏 DOM 不会被替换，widget 脚本无需重复执行。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;六、踩坑记录&lt;/h1&gt;
&lt;h2&gt;克隆体不可见&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;#content-wrapper&lt;/code&gt; 带有 &lt;code&gt;onload-animation&lt;/code&gt; 类（&lt;code&gt;opacity: 0; animation: 300ms fade-in-up&lt;/code&gt;）。&lt;code&gt;cloneNode(true)&lt;/code&gt; 会继承这个类，克隆体追加到 &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; 后动画从头播放，初始 &lt;code&gt;opacity: 0&lt;/code&gt; 导致克隆体完全隐形。&lt;/p&gt;
&lt;p&gt;修复：在克隆体的 inline style 中设置 &lt;code&gt;opacity:1;animation:none;&lt;/code&gt; 覆盖继承的动画。&lt;/p&gt;
&lt;h2&gt;侧栏跟随掉落&lt;/h2&gt;
&lt;p&gt;最初克隆整个 &lt;code&gt;#main-grid&lt;/code&gt;（含侧栏），导致侧栏跟着一起掉。改为只克隆 &lt;code&gt;#content-wrapper&lt;/code&gt;（纯内容列），侧栏不在克隆范围内，始终保持不动。&lt;/p&gt;
&lt;h2&gt;内容注入闪烁&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;swupEl.innerHTML = newSwup.innerHTML&lt;/code&gt; 替换内容后，新的 &lt;code&gt;#content-wrapper&lt;/code&gt; 带有 &lt;code&gt;onload-animation&lt;/code&gt; 类，从 &lt;code&gt;opacity: 0&lt;/code&gt; 渐显到 &lt;code&gt;1&lt;/code&gt;，造成&quot;空白闪烁&quot;。&lt;/p&gt;
&lt;p&gt;修复：注入后立即设置 &lt;code&gt;newContentEl.style.opacity = &apos;0&apos;&lt;/code&gt;，等动画克隆体消失后（&lt;code&gt;reveal()&lt;/code&gt;）再瞬间设为 &lt;code&gt;1&lt;/code&gt;，无渐显过渡。&lt;/p&gt;
&lt;h2&gt;侧栏点击全量刷新&lt;/h2&gt;
&lt;p&gt;最初侧栏链接使用 &lt;code&gt;window.location.href = url&lt;/code&gt;，导致整页刷新。改用 &lt;code&gt;window.swup.navigate(url)&lt;/code&gt; 做 SPA 跳转，只替换 Swup 容器内容，侧栏和其他区域不受影响。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;七、文件清单&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;操作&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/i18n/i18nKey.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;修改&lt;/td&gt;
&lt;td&gt;新增 4 个枚举值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/i18n/languages/*.ts&lt;/code&gt;（9个）&lt;/td&gt;
&lt;td&gt;修改&lt;/td&gt;
&lt;td&gt;各语言翻译&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/global.d.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;修改&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ReadLaterStore&lt;/code&gt; 类型声明&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/components/ReadLaterButton.astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;新建&lt;/td&gt;
&lt;td&gt;书签按钮 + 自由落体动画 + &lt;code&gt;ReadLaterStore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/components/widget/ReadLater.astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;新建&lt;/td&gt;
&lt;td&gt;侧栏 widget + 事件委托 + Swup 钩子&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/pages/posts/[...slug].astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;修改&lt;/td&gt;
&lt;td&gt;插入 &lt;code&gt;ReadLaterButton&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/components/widget/SideBar.astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;修改&lt;/td&gt;
&lt;td&gt;插入 &lt;code&gt;ReadLater&lt;/code&gt; widget&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>Fuwari 匿名留言板功能</title><link>https://blog.yuer6327.top/posts/fuwari%E5%8C%BF%E5%90%8D%E7%95%99%E8%A8%80%E6%9D%BF%E5%8A%9F%E8%83%BD/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/fuwari%E5%8C%BF%E5%90%8D%E7%95%99%E8%A8%80%E6%9D%BF%E5%8A%9F%E8%83%BD/</guid><description>使用 Supabase + 原生 JS 为 Fuwari 静态博客添加匿名说说/留言板</description><pubDate>Sat, 23 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在博客中添加一个 &lt;code&gt;/board/&lt;/code&gt; 页面，读者可以匿名发布最多 200 字的短消息，支持自定义头像（邮箱 Gravatar 或直链图片），留言以社交动态流的形式展示。数据存储在 Supabase 中，全程客户端原生 JS 实现，不引入额外依赖。&lt;/p&gt;
&lt;p&gt;demo：&lt;a href=&quot;/board/&quot;&gt;说说&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;一、Supabase 数据库设置&lt;/h1&gt;
&lt;h2&gt;1. 建表&lt;/h2&gt;
&lt;p&gt;进入 Supabase 项目的 SQL Editor，执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE messages (
    id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
    content text NOT NULL CHECK (char_length(content) &amp;lt;= 200 AND char_length(content) &amp;gt; 0),
    nickname text DEFAULT &apos;匿名&apos;,
    avatar text DEFAULT &apos;&apos;,
    fingerprint text NOT NULL,
    created_at timestamptz DEFAULT now()
);

-- 启用行级安全策略
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- 公开读取
CREATE POLICY &quot;public_select&quot; ON messages
    FOR SELECT USING (true);

-- 公开写入
CREATE POLICY &quot;public_insert&quot; ON messages
    FOR INSERT WITH CHECK (true);

-- 允许删除（实际所有权校验在客户端通过 fingerprint 完成）
CREATE POLICY &quot;owner_delete&quot; ON messages
    FOR DELETE USING (true);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;字段说明：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;uuid&lt;/td&gt;
&lt;td&gt;主键，自动生成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;留言内容，最长 200 字，数据库层面用 CHECK 约束&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nickname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;昵称，默认&quot;匿名&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;avatar&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;头像 URL（已解析后的），默认空字符串表示使用首字彩色圆圈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fingerprint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text&lt;/td&gt;
&lt;td&gt;浏览器指纹，用于标识匿名用户身份&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timestamptz&lt;/td&gt;
&lt;td&gt;创建时间，默认当前时间&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;删除策略用 &lt;code&gt;USING (true)&lt;/code&gt; 是因为 RLS 无法直接匹配客户端计算的 fingerprint。实际的&quot;只能删除自己的留言&quot;逻辑在客户端实现：删除请求的 URL 同时携带 &lt;code&gt;id=eq.X&lt;/code&gt; 和 &lt;code&gt;fingerprint=eq.Y&lt;/code&gt; 两个条件，PostgREST 只删除同时满足两者的行。对于匿名留言板这个安全等级足够。&lt;/p&gt;
&lt;h2&gt;2. 获取凭据&lt;/h2&gt;
&lt;p&gt;如果之前做过&lt;a href=&quot;/posts/fuwari%E6%96%87%E7%AB%A0%E5%8F%8D%E5%BA%94%E5%8A%9F%E8%83%BD/&quot;&gt;文章反应功能&lt;/a&gt;，Supabase 凭据已经配置好了，跳过此步。&lt;/p&gt;
&lt;p&gt;否则进入 Supabase 控制台 → Settings → API，记录 &lt;strong&gt;Project URL&lt;/strong&gt; 和 &lt;strong&gt;anon public key&lt;/strong&gt;，写入 &lt;code&gt;.env&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PUBLIC_SUPABASE_URL=https://xxx.supabase.co
PUBLIC_SUPABASE_ANON_KEY=eyJ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署在 Cloudflare Pages 的话，在 Pages → Settings → Environment variables 中同步添加。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二、创建留言板页面&lt;/h1&gt;
&lt;p&gt;新建 &lt;code&gt;src/pages/board.astro&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;页面采用社交动态流风格，不使用卡片堆叠。结构分为四个区域：标题栏、输入区、消息列表、加载更多。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import MainGridLayout from &quot;../layouts/MainGridLayout.astro&quot;;
import { Icon } from &quot;astro-icon/components&quot;;
---

&amp;lt;MainGridLayout title=&quot;说说&quot; description=&quot;留言板 - 分享碎片化的想法&quot;&amp;gt;
    &amp;lt;!-- 页面标题栏 --&amp;gt;
    &amp;lt;div class=&quot;flex items-center gap-3 mb-6 onload-animation&quot;
         style=&quot;animation-delay: var(--content-delay)&quot;&amp;gt;
        &amp;lt;div class=&quot;w-9 h-9 rounded-lg bg-[var(--btn-regular-bg)] flex items-center justify-center&quot;&amp;gt;
            &amp;lt;Icon name=&quot;material-symbols:chat-bubble-outline-rounded&quot;
                  class=&quot;text-lg text-[var(--btn-content)]&quot; /&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;h1 class=&quot;text-2xl font-bold text-90&quot;&amp;gt;说说&amp;lt;/h1&amp;gt;
        &amp;lt;span id=&quot;board-count&quot; class=&quot;text-sm text-50 ml-auto&quot;&amp;gt;&amp;lt;/span&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 输入区域 --&amp;gt;
    &amp;lt;div class=&quot;rounded-2xl bg-[var(--card-bg)] transition-all duration-300
                ease-out mb-6 onload-animation overflow-hidden&quot;
         style=&quot;animation-delay: calc(var(--content-delay) + 50ms)&quot;&amp;gt;
        &amp;lt;!-- 收起态：单行提示 --&amp;gt;
        &amp;lt;div id=&quot;compose-collapsed&quot;
             class=&quot;flex items-center gap-3 px-4 py-3 cursor-text group&quot;&amp;gt;
            &amp;lt;div class=&quot;w-8 h-8 rounded-full flex items-center justify-center
                        flex-shrink-0&quot;
                 style=&quot;background: oklch(0.75 0.14 var(--hue) / 0.15)&quot;&amp;gt;
                &amp;lt;Icon name=&quot;material-symbols:edit-rounded&quot;
                      class=&quot;text-sm text-[var(--primary)]&quot; /&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;text-50 text-sm group-hover:text-[var(--primary)]
                        transition flex-1&quot;&amp;gt;
                分享你的想法...
            &amp;lt;/div&amp;gt;
            &amp;lt;button class=&quot;btn-regular rounded-lg h-8 px-3 text-xs font-medium
                           active:scale-95 opacity-50 pointer-events-none&quot;&amp;gt;
                发布
            &amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;!-- 展开态 --&amp;gt;
        &amp;lt;div id=&quot;compose-expanded&quot; class=&quot;compose-panel&quot;&amp;gt;
            &amp;lt;!-- 昵称 + 头像预览 --&amp;gt;
            &amp;lt;div class=&quot;flex items-center gap-2 mb-3 pb-2
                        border-b border-dashed border-[var(--line-divider)]&quot;&amp;gt;
                &amp;lt;div id=&quot;compose-avatar-preview&quot;
                     class=&quot;w-8 h-8 rounded-full flex items-center justify-center
                            flex-shrink-0 text-xs font-bold text-white overflow-hidden&quot;
                     style=&quot;background: oklch(0.75 0.14 var(--hue) / 0.3)&quot;&amp;gt;
                    &amp;lt;Icon name=&quot;material-symbols:person-rounded&quot;
                          class=&quot;text-sm text-[var(--primary)]&quot; /&amp;gt;
                &amp;lt;/div&amp;gt;
                &amp;lt;input id=&quot;compose-nickname&quot; type=&quot;text&quot; maxlength=&quot;20&quot;
                    placeholder=&quot;昵称 (选填，默认匿名)&quot;
                    class=&quot;flex-1 bg-transparent text-90 text-sm outline-none
                           placeholder:text-50 placeholder:text-sm&quot; /&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;!-- 头像输入 --&amp;gt;
            &amp;lt;input id=&quot;compose-avatar&quot; type=&quot;text&quot; maxlength=&quot;200&quot;
                placeholder=&quot;头像：邮箱 或 图片链接 (选填)&quot;
                class=&quot;w-full mb-2 bg-transparent text-90 text-sm outline-none
                       placeholder:text-50 placeholder:text-xs pb-2
                       border-b border-dashed border-[var(--line-divider)]&quot; /&amp;gt;
            &amp;lt;!-- 内容输入 --&amp;gt;
            &amp;lt;textarea id=&quot;compose-textarea&quot; maxlength=&quot;200&quot; rows=&quot;3&quot;
                placeholder=&quot;说点什么吧...&quot;
                class=&quot;w-full min-h-[5rem] bg-transparent text-90 text-sm
                       resize-none outline-none placeholder:text-50
                       placeholder:text-sm&quot;&amp;gt;&amp;lt;/textarea&amp;gt;
            &amp;lt;!-- 底部操作栏 --&amp;gt;
            &amp;lt;div class=&quot;flex items-center justify-between mt-3 pt-3
                        border-t border-dashed border-[var(--line-divider)]&quot;&amp;gt;
                &amp;lt;span id=&quot;char-count&quot; class=&quot;text-30 text-xs&quot;&amp;gt;0 / 200&amp;lt;/span&amp;gt;
                &amp;lt;div class=&quot;flex items-center gap-2&quot;&amp;gt;
                    &amp;lt;span id=&quot;board-error&quot;
                          class=&quot;text-sm text-red-500 hidden&quot;&amp;gt;&amp;lt;/span&amp;gt;
                    &amp;lt;button id=&quot;compose-cancel&quot;
                            class=&quot;btn-plain rounded-lg h-8 px-3 text-xs
                                   font-medium&quot;&amp;gt;
                        取消
                    &amp;lt;/button&amp;gt;
                    &amp;lt;button id=&quot;compose-submit&quot;
                            class=&quot;btn-regular rounded-lg h-8 px-4 text-xs
                                   font-medium active:scale-95 opacity-50
                                   pointer-events-none&quot;&amp;gt;
                        发布
                    &amp;lt;/button&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 消息列表容器，由 JS 动态填充 --&amp;gt;
    &amp;lt;div id=&quot;board-messages&quot; data-initialized=&quot;false&quot; data-offset=&quot;0&quot;&amp;gt;
        &amp;lt;div id=&quot;board-loading&quot;
             class=&quot;flex flex-col items-center justify-center py-16&quot;&amp;gt;
            &amp;lt;div class=&quot;w-12 h-12 rounded-full bg-[var(--btn-regular-bg)]
                        flex items-center justify-center mb-3 animate-pulse&quot;&amp;gt;
                &amp;lt;Icon name=&quot;material-symbols:progress-activity&quot;
                      class=&quot;text-xl text-[var(--btn-content)] animate-spin&quot; /&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;text-50 text-sm&quot;&amp;gt;加载中...&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 空状态 --&amp;gt;
    &amp;lt;div id=&quot;board-empty&quot;
         class=&quot;hidden flex-col items-center justify-center py-20 text-center&quot;&amp;gt;
        &amp;lt;div class=&quot;w-16 h-16 rounded-full bg-[var(--btn-regular-bg)]
                    flex items-center justify-center mb-4&quot;&amp;gt;
            &amp;lt;Icon name=&quot;material-symbols:chat-bubble-outline-rounded&quot;
                  class=&quot;text-3xl text-[var(--btn-content)]&quot; /&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;text-75 text-base font-medium mb-2&quot;&amp;gt;还没有说说&amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;text-50 text-sm&quot;&amp;gt;快来发布第一条想法吧&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 加载更多 --&amp;gt;
    &amp;lt;div id=&quot;board-load-more-area&quot;
         class=&quot;hidden flex-col items-center py-6 gap-3&quot;&amp;gt;
        &amp;lt;button id=&quot;board-load-more&quot;
                class=&quot;btn-regular rounded-lg h-9 px-6 text-sm font-medium
                       active:scale-95&quot;&amp;gt;
            加载更多
        &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div id=&quot;board-load-more-loading&quot;
         class=&quot;hidden flex items-center justify-center gap-2 py-6 text-50
                text-sm&quot;&amp;gt;
        &amp;lt;Icon name=&quot;material-symbols:progress-activity&quot;
              class=&quot;text-base animate-spin&quot; /&amp;gt;
        加载中...
    &amp;lt;/div&amp;gt;
    &amp;lt;div id=&quot;board-no-more&quot;
         class=&quot;hidden text-center py-6 text-30 text-sm&quot;&amp;gt;
        - 没有更多了 -
    &amp;lt;/div&amp;gt;

    &amp;lt;!-- 展开动画和头像预览样式 --&amp;gt;
    &amp;lt;style is:inline&amp;gt;
        #compose-collapsed:hover .btn-regular {
            opacity: 0.85;
            pointer-events: auto;
        }
        .compose-panel {
            max-height: 0;
            opacity: 0;
            overflow: hidden;
            padding: 0 1rem;
            transition: max-height 0.35s ease-out,
                        opacity 0.25s ease-out,
                        padding 0.3s ease-out;
        }
        .compose-panel.expanded {
            max-height: 400px;
            opacity: 1;
            padding: 1rem;
        }
        #compose-avatar-preview img {
            width: 100%;
            height: 100%;
            object-fit: cover;
            border-radius: 9999px;
        }
    &amp;lt;/style&amp;gt;
&amp;lt;/MainGridLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;要点说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;展开动画&lt;/strong&gt;：收起态和展开态不用 &lt;code&gt;hidden&lt;/code&gt; 类切换（瞬间跳变），而是用 &lt;code&gt;max-height&lt;/code&gt; + &lt;code&gt;opacity&lt;/code&gt; 过渡。&lt;code&gt;.compose-panel&lt;/code&gt; 默认 &lt;code&gt;max-height: 0; opacity: 0&lt;/code&gt;，添加 &lt;code&gt;.expanded&lt;/code&gt; 类后过渡到 &lt;code&gt;max-height: 400px; opacity: 1&lt;/code&gt;，配合 &lt;code&gt;padding&lt;/code&gt; 变化实现平滑展开收起&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;头像预览&lt;/strong&gt;：&lt;code&gt;compose-avatar-preview&lt;/code&gt; 是一个 8×8 的圆形区域，输入头像地址后 JS 会自动更新其内容为 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 标签&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;样式使用 &lt;code&gt;&amp;lt;style is:inline&amp;gt;&lt;/code&gt;&lt;/strong&gt;：与 Reactions 组件一致，避免 Astro 的 scoped style 属性选择器干扰动态 DOM&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;onload-animation&lt;/code&gt;&lt;/strong&gt;：标题栏和输入区域各自带递增的 &lt;code&gt;animation-delay&lt;/code&gt;，复用 Fuwari 已有的 &lt;code&gt;fade-in-up&lt;/code&gt; 入场动画&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;三、Layout.astro 添加交互逻辑&lt;/h1&gt;
&lt;p&gt;在 Layout.astro 的主 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 块中，&lt;code&gt;initReactions()&lt;/code&gt; 函数之后添加 &lt;code&gt;initBoardMessages()&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;1. 辅助函数&lt;/h2&gt;
&lt;h3&gt;相对时间&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;function timeAgo(dateStr) {
    const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
    if (seconds &amp;lt; 60) return &apos;刚刚&apos;;
    const minutes = Math.floor(seconds / 60);
    if (minutes &amp;lt; 60) return `${minutes}分钟前`;
    const hours = Math.floor(minutes / 60);
    if (hours &amp;lt; 24) return `${hours}小时前`;
    const days = Math.floor(hours / 24);
    if (days &amp;lt; 30) return `${days}天前`;
    const months = Math.floor(days / 30);
    return `${months}个月前`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;XSS 转义&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;function escapeHtml(text) {
    const div = document.createElement(&apos;div&apos;);
    div.textContent = text;
    return div.innerHTML;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有用户输入（昵称、内容）在插入 innerHTML 前必须经过这个函数。&lt;code&gt;textContent&lt;/code&gt; 赋值时浏览器会自动转义 &lt;code&gt;&amp;lt;&lt;/code&gt;、&lt;code&gt;&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;amp;&lt;/code&gt; 等字符，再通过 &lt;code&gt;innerHTML&lt;/code&gt; 读出的就是安全的 HTML 实体。&lt;/p&gt;
&lt;h3&gt;头像 URL 解析&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;async function resolveAvatarUrl(raw) {
    const trimmed = raw.trim();
    if (!trimmed) return &apos;&apos;;
    if (trimmed.includes(&apos;@&apos;)) {
        // 邮箱 → loli.net Gravatar 镜像 (SHA-256)
        const data = new TextEncoder().encode(trimmed.toLowerCase());
        const hashBuffer = await crypto.subtle.digest(&apos;SHA-256&apos;, data);
        const hash = Array.from(new Uint8Array(hashBuffer))
            .map(b =&amp;gt; b.toString(16).padStart(2, &apos;0&apos;)).join(&apos;&apos;);
        return `https://gravatar.loli.net/avatar/${hash}?d=404`;
    }
    return trimmed; // 直链直接返回
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两种头像来源的判断逻辑：包含 &lt;code&gt;@&lt;/code&gt; 视为邮箱，计算 SHA-256 哈希后拼接 Gravatar 镜像地址；否则视为直链图片 URL。&lt;/p&gt;
&lt;p&gt;Gravatar 标准使用 MD5 哈希邮箱，但 &lt;code&gt;gravatar.loli.net&lt;/code&gt; 同时支持 SHA-256。使用 SHA-256 的原因是浏览器原生的 SubtleCrypto API 只提供 SHA 系列哈希，无需额外引入 MD5 库。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;?d=404&lt;/code&gt; 参数表示：如果该邮箱没有设置 Gravatar 头像，返回 404 而非默认图片。前端检测到 404 后会回退显示首字彩色圆圈。&lt;/p&gt;
&lt;h3&gt;头像 HTML 生成&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;function getAvatarHtml(avatarUrl, nickname) {
    const firstChar = (nickname || &apos;匿&apos;).charAt(0);
    const hue = (firstChar.charCodeAt(0) * 37) % 360;
    if (avatarUrl) {
        return `&amp;lt;div class=&quot;w-10 h-10 rounded-full flex-shrink-0
                            overflow-hidden bg-[var(--btn-regular-bg)]&quot;&amp;gt;
            &amp;lt;img src=&quot;${escapeHtml(avatarUrl)}&quot; alt=&quot;&quot;
                 class=&quot;w-full h-full object-cover&quot;
                 onerror=&quot;this.parentElement.innerHTML=&apos;...&apos;&quot;/&amp;gt;
        &amp;lt;/div&amp;gt;`;
    }
    // 无头像：显示首字彩色圆圈
    return `&amp;lt;div class=&quot;...&quot; style=&quot;background-color: oklch(0.65 0.15 ${hue})&quot;&amp;gt;
        ${escapeHtml(firstChar)}
    &amp;lt;/div&amp;gt;`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;头像颜色由昵称首字的 Unicode 编码确定性生成：&lt;code&gt;charCode * 37 % 360&lt;/code&gt; 映射到 oklch 色环的 0-359 度。同一个昵称总是同一个颜色。oklch 的 C（色度）和 L（亮度）固定，只变化 H（色相），保证所有头像的视觉亮度和饱和度一致。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;onerror&lt;/code&gt; 回退逻辑：&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 加载失败时（404 或网络错误），把父容器的 innerHTML 替换为首字圆圈。这样即使用户填错了图片地址，也不会显示破碎图片图标。&lt;/p&gt;
&lt;h2&gt;2. 消息列表渲染&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;function createMessageEl(msg, animate) {
    const el = document.createElement(&apos;div&apos;);
    el.className = animate ? &apos;msg-enter&apos; : &apos;&apos;;
    el.dataset.msgId = msg.id;
    const isOwner = msg.fingerprint === fingerprint;
    el.innerHTML = `
        &amp;lt;div class=&quot;flex gap-3 py-5 px-1 group&quot;&amp;gt;
            ${getAvatarHtml(msg.avatar || &apos;&apos;, msg.nickname || &apos;匿名&apos;)}
            &amp;lt;div class=&quot;flex-1 min-w-0&quot;&amp;gt;
                &amp;lt;div class=&quot;flex items-baseline gap-2 mb-1.5&quot;&amp;gt;
                    &amp;lt;span class=&quot;font-bold text-sm text-90&quot;&amp;gt;
                        ${escapeHtml(msg.nickname || &apos;匿名&apos;)}
                    &amp;lt;/span&amp;gt;
                    &amp;lt;span class=&quot;text-30 text-xs&quot;&amp;gt;
                        ${timeAgo(msg.created_at)}
                    &amp;lt;/span&amp;gt;
                    ${isOwner ? &apos;&amp;lt;button class=&quot;board-delete-btn ...&quot; data-id=&quot;...&quot;&amp;gt;删除&amp;lt;/button&amp;gt;&apos; : &apos;&apos;}
                &amp;lt;/div&amp;gt;
                &amp;lt;div class=&quot;text-75 text-sm leading-relaxed
                            break-words whitespace-pre-wrap&quot;&amp;gt;
                    ${escapeHtml(msg.content)}
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;border-t border-dashed border-black/10
                    dark:border-white/[0.15]&quot;&amp;gt;&amp;lt;/div&amp;gt;
    `;
    return el;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;要点说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每条消息是一行 flex 布局：左侧 10×10 圆形头像，右侧昵称 + 时间 + 内容&lt;/li&gt;
&lt;li&gt;虚线分隔线复用了 Fuwari 的 PostCard 分隔线样式（&lt;code&gt;border-dashed border-black/10 dark:border-white/[0.15]&lt;/code&gt;），自动适配亮暗主题&lt;/li&gt;
&lt;li&gt;删除按钮用 &lt;code&gt;opacity-0 group-hover:opacity-100&lt;/code&gt; 实现 hover 显示，不影响默认布局&lt;/li&gt;
&lt;li&gt;&lt;code&gt;animate&lt;/code&gt; 参数控制是否添加 &lt;code&gt;msg-enter&lt;/code&gt; 类（入场动画），初始加载和加载更多用 &lt;code&gt;true&lt;/code&gt;，后续追加用 &lt;code&gt;false&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 数据获取&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;async function fetchMessages(offset, limit) {
    const resp = await fetch(
        `${supabaseUrl}/rest/v1/messages?` +
        `select=id,content,nickname,avatar,fingerprint,created_at` +
        `&amp;amp;order=created_at.desc&amp;amp;limit=${limit}&amp;amp;offset=${offset}`,
        {
            headers: {
                &apos;apikey&apos;: supabaseKey,
                &apos;Authorization&apos;: `Bearer ${supabaseKey}`,
            },
            signal: controller.signal,
        }
    );
    if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`);
    return await resp.json();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用 PostgREST 的 &lt;code&gt;order=created_at.desc&lt;/code&gt; 实现最新消息在前，&lt;code&gt;limit&lt;/code&gt; + &lt;code&gt;offset&lt;/code&gt; 实现分页。每次加载 20 条。&lt;/p&gt;
&lt;h2&gt;4. 输入区域交互&lt;/h2&gt;
&lt;p&gt;展开收起通过切换 CSS 类实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const collapsed = document.getElementById(&apos;compose-collapsed&apos;);
const expanded = document.getElementById(&apos;compose-expanded&apos;);

function expandCompose() {
    collapsed.classList.add(&apos;hidden&apos;);
    expanded.classList.add(&apos;expanded&apos;);   // 触发 CSS 过渡动画
    textarea.focus();
}

function collapseCompose() {
    expanded.classList.remove(&apos;expanded&apos;);
    collapsed.classList.remove(&apos;hidden&apos;);
    textarea.value = &apos;&apos;;
    // 昵称和头像不清除，保留供下次使用
}

collapsed.addEventListener(&apos;click&apos;, expandCompose);
cancelBtn.addEventListener(&apos;click&apos;, collapseCompose);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;收起时清除内容但保留昵称和头像输入——这两个值已持久化到 localStorage，清除输入框只是视觉层面的，下次展开时会自动恢复。&lt;/p&gt;
&lt;p&gt;头像输入带 500ms 防抖预览：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let avatarDebounce;
avatarInput.addEventListener(&apos;input&apos;, () =&amp;gt; {
    clearTimeout(avatarDebounce);
    avatarDebounce = setTimeout(updateAvatarPreview, 500);
});

async function updateAvatarPreview() {
    const raw = avatarInput.value.trim();
    if (!raw) {
        // 无输入时显示默认 person 图标
        avatarPreview.innerHTML = &apos;&amp;lt;svg ...&amp;gt;...&amp;lt;/svg&amp;gt;&apos;;
        return;
    }
    const url = await resolveAvatarUrl(raw);
    avatarPreview.innerHTML = `&amp;lt;img src=&quot;${url}&quot; onerror=&quot;...&quot; /&amp;gt;`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;防抖的原因：邮箱地址需要做 SHA-256 哈希，虽然是异步操作但频繁触发也无意义。500ms 内用户停止输入后才实际解析并加载预览图。&lt;/p&gt;
&lt;h2&gt;5. 提交留言&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;submitBtn.addEventListener(&apos;click&apos;, async () =&amp;gt; {
    const content = textarea.value.trim();
    if (!content || content.length &amp;gt; 200) return;

    // 关键词过滤
    if (content.includes(&apos;徐博闻&apos;)) {
        errorEl.textContent = &apos;内容包含不允许的词汇&apos;;
        errorEl.classList.remove(&apos;hidden&apos;);
        return;
    }

    // 30 秒冷却
    const lastSubmit = localStorage.getItem(&apos;board_last_submit&apos;);
    if (lastSubmit) {
        const elapsed = Date.now() - parseInt(lastSubmit);
        if (elapsed &amp;lt; 30000) {
            errorEl.textContent = `操作太频繁，请${Math.ceil((30000 - elapsed) / 1000)}秒后再试`;
            errorEl.classList.remove(&apos;hidden&apos;);
            return;
        }
    }

    const nickname = nicknameInput.value.trim() || &apos;匿名&apos;;
    const avatarRaw = avatarInput.value.trim() || &apos;&apos;;
    const avatar = avatarRaw ? await resolveAvatarUrl(avatarRaw) : &apos;&apos;;

    const resp = await fetch(`${supabaseUrl}/rest/v1/messages`, {
        method: &apos;POST&apos;,
        headers: {
            &apos;apikey&apos;: supabaseKey,
            &apos;Authorization&apos;: `Bearer ${supabaseKey}`,
            &apos;Content-Type&apos;: &apos;application/json&apos;,
            &apos;Prefer&apos;: &apos;return=representation&apos;,
        },
        body: JSON.stringify({ content, nickname, avatar, fingerprint }),
    });

    const [newMsg] = await resp.json();

    // 持久化用户信息
    localStorage.setItem(&apos;board_nickname&apos;, nickname);
    if (avatarRaw) localStorage.setItem(&apos;board_avatar&apos;, avatarRaw);
    localStorage.setItem(&apos;board_last_submit&apos;, String(Date.now()));

    // 乐观插入到列表顶部
    container.prepend(createMessageEl(newMsg, true));
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个防护机制：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;关键词过滤&lt;/strong&gt;：客户端 &lt;code&gt;includes()&lt;/code&gt; 检查，硬编码在代码中。不是安全防线，只是第一层拦截&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;冷却时间&lt;/strong&gt;：localStorage 记录上次提交时间戳，30 秒内不可重复提交。跨标签页有效（localStorage 共享）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据库约束&lt;/strong&gt;：&lt;code&gt;CHECK (char_length(content) &amp;lt;= 200)&lt;/code&gt; 在数据库层面兜底，即使绕过前端也能阻止超长内容&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;Prefer: return=representation&lt;/code&gt; 让 PostgREST 在 INSERT 后返回完整的插入记录（包括数据库生成的 &lt;code&gt;id&lt;/code&gt; 和 &lt;code&gt;created_at&lt;/code&gt;），拿到后直接用 &lt;code&gt;createMessageEl&lt;/code&gt; 渲染并 prepend 到列表顶部。&lt;/p&gt;
&lt;p&gt;头像在提交时先通过 &lt;code&gt;resolveAvatarUrl&lt;/code&gt; 解析为最终 URL 再存入数据库，这样读取时不需要再做邮箱→URL 的转换。&lt;/p&gt;
&lt;h2&gt;6. 删除留言&lt;/h2&gt;
&lt;p&gt;使用事件委托，监听整个消息容器的 click 事件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;container.addEventListener(&apos;click&apos;, async (e) =&amp;gt; {
    const target = e.target;
    if (!target.classList.contains(&apos;board-delete-btn&apos;)) return;
    const msgId = target.dataset.id;
    if (!msgId || !confirm(&apos;确定要删除这条留言吗？&apos;)) return;

    const resp = await fetch(
        `${supabaseUrl}/rest/v1/messages` +
        `?id=eq.${encodeURIComponent(msgId)}` +
        `&amp;amp;fingerprint=eq.${encodeURIComponent(fingerprint)}`,
        { method: &apos;DELETE&apos;, headers: { ... } }
    );

    if (resp.ok) {
        const msgEl = target.closest(&apos;[data-msg-id]&apos;);
        // 淡出动画后移除 DOM
        msgEl.style.transition = &apos;opacity 0.3s, max-height 0.3s, ...&apos;;
        msgEl.style.opacity = &apos;0&apos;;
        requestAnimationFrame(() =&amp;gt; {
            msgEl.style.maxHeight = &apos;0&apos;;
            msgEl.style.overflow = &apos;hidden&apos;;
        });
        setTimeout(() =&amp;gt; msgEl.remove(), 300);
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;删除请求的 URL 同时带 &lt;code&gt;id&lt;/code&gt; 和 &lt;code&gt;fingerprint&lt;/code&gt; 两个过滤条件，PostgREST 只会删除同时匹配的行，保证用户只能删自己的留言。&lt;/p&gt;
&lt;p&gt;移除 DOM 前先播放一个 300ms 的淡出收缩动画：&lt;code&gt;opacity&lt;/code&gt; → 0，&lt;code&gt;max-height&lt;/code&gt; → 0，视觉上消息&quot;缩回消失&quot;，动画结束后 &lt;code&gt;remove()&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;四、接入 Swup 生命周期&lt;/h1&gt;
&lt;p&gt;Fuwari 使用 Swup 做页面切换。Swup 切页时会替换 &lt;code&gt;#swup-container&lt;/code&gt; 内的全部 DOM，留言板页面的 &lt;code&gt;data-initialized&lt;/code&gt; 会被重置为 &lt;code&gt;false&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在两个位置调用 &lt;code&gt;initBoardMessages()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 首次加载
function init() {
    loadTheme();
    loadHue();
    setTimeout(() =&amp;gt; { initTwikoo() }, 100)
    initReactions()
    initBoardMessages()  // ← 添加
}

// swup 切页后
window.swup.hooks.on(&apos;page:view&apos;, () =&amp;gt; {
    // ...
    initReactions()
    initBoardMessages()  // ← 添加
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;函数入口的 guard 检查确保只在留言板页面执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function initBoardMessages() {
    const container = document.getElementById(&apos;board-messages&apos;);
    if (!container || container.dataset.initialized === &apos;true&apos;) return;
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其他页面没有 &lt;code&gt;#board-messages&lt;/code&gt; 元素，&lt;code&gt;getElementById&lt;/code&gt; 返回 null，函数直接退出，零开销。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;五、动画&lt;/h1&gt;
&lt;p&gt;编辑 &lt;code&gt;src/styles/transition.css&lt;/code&gt;，追加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* 留言条目入场动画 */
@keyframes msg-slide-in {
    0% { transform: translateY(0.75rem); opacity: 0; }
    100% { transform: translateY(0); opacity: 1; }
}
.msg-enter { animation: 300ms msg-slide-in ease-out; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比 Fuwari 已有的 &lt;code&gt;fade-in-up&lt;/code&gt;（位移 2rem）更轻微（位移 0.75rem），适合消息列表中逐条出现的场景，不至于太&quot;跳&quot;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;六、导航链接&lt;/h1&gt;
&lt;p&gt;编辑 &lt;code&gt;src/config.ts&lt;/code&gt;，在 &lt;code&gt;navBarConfig.links&lt;/code&gt; 中添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    name: &quot;说说&quot;,
    url: &quot;/board/&quot;,
    external: false,
},
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;七、keep-alive&lt;/h1&gt;
&lt;p&gt;编辑 &lt;code&gt;scripts/keep-alive.mjs&lt;/code&gt;，在 &lt;code&gt;methods&lt;/code&gt; 数组中添加对 messages 表的定期查询：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    name: &quot;messages query&quot;,
    fn: async () =&amp;gt; {
        const limit = randomInt(1, 5);
        const resp = await fetch(
            `${supabaseUrl}/rest/v1/messages?select=id&amp;amp;limit=${limit}`,
            {
                headers: {
                    apikey: supabaseKey,
                    Authorization: `Bearer ${supabaseKey}`,
                },
            }
        );
        if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
    },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Supabase 免费项目 7 天无活动会自动暂停。已有 GitHub Actions 每天两次执行 keep-alive 脚本查询 reactions 表，现在加上 messages 表一起保活。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;八、LocalStorage 持久化&lt;/h1&gt;
&lt;p&gt;留言板用到以下几个 localStorage key：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;th&gt;生命周期&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reactions_fp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;浏览器指纹&lt;/td&gt;
&lt;td&gt;永久（复用反应功能的指纹）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;board_nickname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用户填写的昵称&lt;/td&gt;
&lt;td&gt;永久&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;board_avatar&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用户填写的头像原始值&lt;/td&gt;
&lt;td&gt;永久&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;board_last_submit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;上次提交时间戳&lt;/td&gt;
&lt;td&gt;永久（用于 30 秒冷却判断）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;昵称和头像在每次提交成功后写入 localStorage，页面加载时自动恢复到输入框。用户不需要每次重新填写。头像原始值（邮箱或 URL）存储的是未解析的原始输入，而非解析后的最终 URL——这样邮箱格式的头像在预览时能正确走 Gravatar 解析流程。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;九、防刷与安全总结&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层级&lt;/th&gt;
&lt;th&gt;机制&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;客户端&lt;/td&gt;
&lt;td&gt;30 秒冷却（localStorage）&lt;/td&gt;
&lt;td&gt;防止短时间内大量提交&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;客户端&lt;/td&gt;
&lt;td&gt;关键词过滤&lt;/td&gt;
&lt;td&gt;第一层内容拦截&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;客户端&lt;/td&gt;
&lt;td&gt;fingerprint 匹配&lt;/td&gt;
&lt;td&gt;只能删除自己的留言&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据库&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CHECK (char_length &amp;lt;= 200)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;内容长度兜底&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据库&lt;/td&gt;
&lt;td&gt;RLS + DELETE 带 fingerprint 条件&lt;/td&gt;
&lt;td&gt;即使绕过前端，也无法删别人的留言&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;浏览器指纹不是密码学身份标识，清 localStorage 或换浏览器/设备就能绕过冷却和删除限制。对于个人博客的留言板来说这个安全等级足够。如果需要更严格的限制，可以加 Supabase Edge Function 做 IP 频率限制，或接入 Turnstile 验证码。&lt;/p&gt;
</content:encoded></item><item><title>Fuwari侧边栏天气组件实现</title><link>https://blog.yuer6327.top/posts/fuwari%E4%BE%A7%E8%BE%B9%E6%A0%8F%E5%A4%A9%E6%B0%94%E7%BB%84%E4%BB%B6/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/fuwari%E4%BE%A7%E8%BE%B9%E6%A0%8F%E5%A4%A9%E6%B0%94%E7%BB%84%E4%BB%B6/</guid><description>为 Astro Fuwari 主题的侧边栏添加天气小组件，使用 Open-Meteo API + Geolocation API，支持自动定位和手动搜城市</description><pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;为 Fuwari 侧边栏添加天气小组件，自动定位后显示当前所在地的温度、天气、湿度、风速等信息，点击设置按钮可手动搜索城市。&lt;/p&gt;
&lt;h1&gt;一、技术选型&lt;/h1&gt;
&lt;h2&gt;1.1 天气 API：Open-Meteo&lt;/h2&gt;
&lt;p&gt;Open-Meteo 是完全免费的天气 API，&lt;strong&gt;无需 API Key&lt;/strong&gt;，无需注册。两个接口：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;接口&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://geocoding-api.open-meteo.com/v1/search?name={城市名}&amp;amp;count=5&amp;amp;language=zh&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://api.open-meteo.com/v1/forecast?latitude={纬度}&amp;amp;longitude={经度}&amp;amp;current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,apparent_temperature&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;1.2 定位：浏览器 Geolocation API&lt;/h2&gt;
&lt;p&gt;优先使用浏览器原生定位，失败时降级为手动搜城市。定位得到经纬度后，再通过 Open-Meteo Geocoding API 反查城市名。&lt;/p&gt;
&lt;h2&gt;1.3 Swup 兼容性&lt;/h2&gt;
&lt;p&gt;Fuwari 使用 Swup 做页面切换。Swup 只替换两个容器的内容：&lt;code&gt;#swup-container&lt;/code&gt;（主内容区）和 &lt;code&gt;#toc&lt;/code&gt;（目录）。&lt;strong&gt;侧边栏在 Swup 容器之外&lt;/strong&gt;，跨页面持久存在，不需要注册任何 Swup hooks。&lt;/p&gt;
&lt;p&gt;这一点很重要：只要把天气组件放在侧边栏，它就不会被 Swup 替换，只需要初始化一次。&lt;/p&gt;
&lt;h1&gt;二、实现步骤&lt;/h1&gt;
&lt;h2&gt;2.1 新建 Weather.astro&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;src/components/widget/&lt;/code&gt; 下新建 &lt;code&gt;Weather.astro&lt;/code&gt;，复用 Fuwari 已有的 &lt;code&gt;WidgetLayout&lt;/code&gt; 组件（和 Tags、Categories 用法一致）。&lt;/p&gt;
&lt;p&gt;组件结构分为三部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;模板&lt;/strong&gt;：Astro 模板，只负责渲染 HTML 骨架，数据由客户端 JS 填充&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SVG 图标&lt;/strong&gt;：WMO 天气码到内联 SVG 的映射表，嵌入在 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 里，不依赖外部 CDN&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;客户端脚本&lt;/strong&gt;：&lt;code&gt;&amp;lt;script is:inline&amp;gt;&lt;/code&gt; 块，负责 API 调用、DOM 操作、缓存管理&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;模板部分&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;---
import I18nKey from &quot;../../i18n/i18nKey&quot;;
import { i18n } from &quot;../../i18n/translation&quot;;
import WidgetLayout from &quot;./WidgetLayout.astro&quot;;
---

&amp;lt;WidgetLayout name={i18n(I18nKey.weather)} id=&quot;weather&quot;&amp;gt;
    &amp;lt;div id=&quot;weather-content&quot;&amp;gt;
        &amp;lt;div id=&quot;weather-loading&quot;&amp;gt;Loading...&amp;lt;/div&amp;gt;
        &amp;lt;div id=&quot;weather-display&quot; class=&quot;hidden&quot;&amp;gt;
            &amp;lt;!-- 图标、温度、描述 --&amp;gt;
            &amp;lt;!-- 湿度、风速、体感温度 --&amp;gt;
            &amp;lt;!-- 城市名 + 设置按钮 --&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div id=&quot;weather-settings&quot; class=&quot;hidden&quot;&amp;gt;
            &amp;lt;!-- 自动定位按钮 --&amp;gt;
            &amp;lt;!-- 城市搜索输入框 + 下拉建议 --&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/WidgetLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模板里没有任何动态数据——温度、图标、城市名全是占位符（&lt;code&gt;--°C&lt;/code&gt;、&lt;code&gt;--&lt;/code&gt;），由客户端脚本填充。&lt;/p&gt;
&lt;p&gt;:::important
必须用 &lt;code&gt;&amp;lt;script is:inline&amp;gt;&lt;/code&gt;，不能用普通的 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;。普通 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 会被 Astro 打包成 ES module 注入 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;，但 Fuwari 配置了 &lt;code&gt;updateHead: false&lt;/code&gt;，Swup 切页时不会重新执行。&lt;code&gt;is:inline&lt;/code&gt; 让脚本直接写入 HTML，页面加载时同步执行——这样才能在首帧渲染前就把 localStorage 缓存的天气数据显示出来，避免页面加载后&quot;闪一下&quot;。
:::&lt;/p&gt;
&lt;h3&gt;SVG 天气图标&lt;/h3&gt;
&lt;p&gt;Open-Meteo 返回的 &lt;code&gt;weather_code&lt;/code&gt; 是 WMO 标准天气码（0 = 晴，2 = 多云，61 = 小雨，95 = 雷暴……）。需要一张映射表把天气码转成图标。&lt;/p&gt;
&lt;p&gt;不用 Iconify CDN，也不装额外依赖——直接把 Material Symbols 的 SVG path 写在 JS 对象里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var WMO_ICONS = {
    0: &apos;&amp;lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot; class=&quot;w-10 h-10&quot;&amp;gt;&amp;lt;path d=&quot;M12 7c-2.76 0-5...&quot;/&amp;gt;&amp;lt;/svg&amp;gt;&apos;,
    2: &apos;&amp;lt;svg viewBox=&quot;0 0 24 24&quot; fill=&quot;currentColor&quot; class=&quot;w-10 h-10&quot;&amp;gt;&amp;lt;path d=&quot;M19.35 10.04A7.49...&quot;/&amp;gt;&amp;lt;/svg&amp;gt;&apos;,
    // ... 其余 WMO 码
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;图标的 SVG 数据从 &lt;code&gt;@iconify-json/material-symbols&lt;/code&gt; 包里提取，也可以直接去 &lt;a href=&quot;https://icon-sets.iconify.design/material-symbols/&quot;&gt;Iconify&lt;/a&gt; 网站复制。天气描述同理，用一个 &lt;code&gt;WMO_DESC&lt;/code&gt; 对象映射成中文。&lt;/p&gt;
&lt;h3&gt;客户端脚本核心逻辑&lt;/h3&gt;
&lt;p&gt;整个脚本包在一个 IIFE 里，用 &lt;code&gt;var&lt;/code&gt; 避免污染全局（&lt;code&gt;is:inline&lt;/code&gt; 脚本不做模块化处理）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;数据流：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;页面加载
  ↓
检查 localStorage 缓存（30 分钟有效期）
  ↓ 有缓存 → 立即渲染，结束
  ↓ 无缓存
尝试 Geolocation API
  ↓ 成功 → 拿到经纬度 → 反查城市名 → 请求天气 → 渲染 + 存缓存
  ↓ 失败 → 显示城市搜索面板，等用户手动输入
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;localStorage 缓存结构：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
    &quot;timestamp&quot;: 1714980000000,
    &quot;temperature&quot;: 23,
    &quot;humidity&quot;: 65,
    &quot;weather_code&quot;: 2,
    &quot;wind&quot;: 12,
    &quot;feels_like&quot;: 21,
    &quot;city&quot;: &quot;上海&quot;,
    &quot;lat&quot;: 31.23,
    &quot;lon&quot;: 121.47
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;反向地理编码：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Geolocation API 只返回经纬度，不返回城市名。用 Open-Meteo Geocoding API 做反查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function reverseGeocode(lat, lon) {
    var url = GEOCODING_API + &quot;?name=&quot; + lat.toFixed(1) + &quot;,&quot; + lon.toFixed(1) + &quot;&amp;amp;count=1&amp;amp;language=zh&quot;;
    var resp = await fetch(url);
    var json = await resp.json();
    if (json.results &amp;amp;&amp;amp; json.results.length &amp;gt; 0) return json.results[0].name;
    return lat.toFixed(2) + &quot;, &quot; + lon.toFixed(2);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;城市搜索（带防抖）：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;输入框监听 &lt;code&gt;input&lt;/code&gt; 事件，300ms 防抖后调用 Geocoding API，结果渲染为下拉列表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cityInput.addEventListener(&quot;input&quot;, function() {
    clearTimeout(debounceTimer);
    var query = cityInput.value.trim();
    if (query.length &amp;lt; 2) { suggestions.classList.add(&quot;hidden&quot;); return; }
    debounceTimer = setTimeout(async function() {
        var results = await searchCity(query);
        // 渲染下拉建议，每项绑定 click → fetchWeather(r.latitude, r.longitude, r.name)
    }, 300);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;点击建议项后清空输入框、隐藏下拉、请求天气数据。&lt;/p&gt;
&lt;h2&gt;2.2 注册到 SideBar.astro&lt;/h2&gt;
&lt;p&gt;编辑 &lt;code&gt;src/components/widget/SideBar.astro&lt;/code&gt;，在 Tags 组件后面加上 Weather：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
// ... 已有 import
import Weather from &quot;./Weather.astro&quot;;
---

&amp;lt;!-- 在 #sidebar-sticky 容器内，Tags 后面 --&amp;gt;
&amp;lt;Tag class=&quot;onload-animation&quot; style=&quot;animation-delay: 200ms&quot;&amp;gt;&amp;lt;/Tag&amp;gt;
&amp;lt;Weather class=&quot;onload-animation&quot; style=&quot;animation-delay: 250ms&quot;&amp;gt;&amp;lt;/Weather&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;animation-delay: 250ms&lt;/code&gt; 保证入场动画在 Tags（200ms）之后，形成依次出现的视觉效果。&lt;/p&gt;
&lt;h2&gt;2.3 添加 i18n 翻译&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;src/i18n/i18nKey.ts&lt;/code&gt; 枚举里加一个键：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;weather = &quot;weather&quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后在每个语言文件里添加对应翻译：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;翻译&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zh_CN.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;天气&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;en.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;Weather&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ja.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;天気&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ko.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;날씨&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;es.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&quot;Clima&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;其余语言文件&lt;/td&gt;
&lt;td&gt;对应翻译&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;i18n&lt;/code&gt; 函数在 Astro 服务端渲染时调用，读的是 &lt;code&gt;siteConfig.lang&lt;/code&gt;，不需要客户端参与。&lt;/p&gt;
</content:encoded></item><item><title>Fuwari文章分享功能实现</title><link>https://blog.yuer6327.top/posts/fuwari%E6%96%87%E7%AB%A0%E5%88%86%E4%BA%AB%E5%8A%9F%E8%83%BD/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/fuwari%E6%96%87%E7%AB%A0%E5%88%86%E4%BA%AB%E5%8A%9F%E8%83%BD/</guid><description>为 Astro Fuwari 主题添加文章分享按钮和社交卡片预览的完整实现过程</description><pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;为 Fuwari 主题实现两个功能：&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;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 注入 &lt;code&gt;og:image&lt;/code&gt; 等 Open Graph 标签，微信/Telegram 等客户端打开链接时自动渲染为带封面图的卡片&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;:::important
Fuwari 使用 &lt;strong&gt;Swup&lt;/strong&gt; 做页面切换，内容区 DOM 会被整体替换，所以事件绑定不能依赖首次加载时的 &lt;code&gt;getElementById&lt;/code&gt;，要用内联 &lt;code&gt;onclick&lt;/code&gt; 或事件委托
:::&lt;/p&gt;
&lt;h1&gt;一、社交卡片：注入 &lt;code&gt;og:image&lt;/code&gt;&lt;/h1&gt;
&lt;h2&gt;1.1 问题&lt;/h2&gt;
&lt;p&gt;Fuwari 的 &lt;code&gt;Layout.astro&lt;/code&gt; 已有 &lt;code&gt;og:title&lt;/code&gt;、&lt;code&gt;og:description&lt;/code&gt;、&lt;code&gt;og:url&lt;/code&gt; 等标签，但没有 &lt;code&gt;og:image&lt;/code&gt;。没有这个标签，微信/Telegram 等客户端打开文章链接时不会显示封面图预览。&lt;/p&gt;
&lt;h2&gt;1.2 给 Layout.astro 添加 ogImage prop&lt;/h2&gt;
&lt;p&gt;编辑 &lt;code&gt;src/layouts/Layout.astro&lt;/code&gt;，在 Props 接口中新增 &lt;code&gt;ogImage&lt;/code&gt; 字段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface Props {
    title?: string;
    banner?: string;
    description?: string;
    lang?: string;
    setOGTypeArticle?: boolean;
    ogImage?: string;  // 新增
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解构处同步添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let { title, banner, description, lang, setOGTypeArticle, ogImage } = Astro.props;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 的 OG 标签区域添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ogImage &amp;amp;&amp;amp; &amp;lt;meta property=&quot;og:image&quot; content={new URL(ogImage, Astro.site).toString()}&amp;gt;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;关键：&lt;/strong&gt; &lt;code&gt;ogImage&lt;/code&gt; 与 &lt;code&gt;banner&lt;/code&gt; 完全独立。&lt;code&gt;Layout.astro&lt;/code&gt; 第 47 行会强制把 &lt;code&gt;banner&lt;/code&gt; 覆盖为站点配置的 banner 图，所以不能复用 &lt;code&gt;banner&lt;/code&gt; 做 &lt;code&gt;og:image&lt;/code&gt;，必须用独立的 prop。&lt;/p&gt;
&lt;h2&gt;1.3 透传到 MainGridLayout.astro&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;MainGridLayout.astro&lt;/code&gt; 是 Layout 和文章页之间的中间层，需要把 &lt;code&gt;ogImage&lt;/code&gt; 透传下去。&lt;/p&gt;
&lt;p&gt;编辑 &lt;code&gt;src/layouts/MainGridLayout.astro&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;Props 接口加 &lt;code&gt;ogImage?: string&lt;/code&gt;，解构加 &lt;code&gt;ogImage&lt;/code&gt;，Layout 调用处加 &lt;code&gt;ogImage={ogImage}&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;Layout title={title} banner={banner} description={description} lang={lang}
        setOGTypeArticle={setOGTypeArticle} ogImage={ogImage}&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;1.4 在文章页传入封面图&lt;/h2&gt;
&lt;p&gt;编辑 &lt;code&gt;src/pages/posts/[...slug].astro&lt;/code&gt;，在 &lt;code&gt;MainGridLayout&lt;/code&gt; 调用处添加 &lt;code&gt;ogImage&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;MainGridLayout
    ...
    ogImage={entry.data.image || &quot;/favicon/gravatar.png&quot;}
    ...&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;逻辑：有封面图用封面图，没有则 fallback 到 &lt;code&gt;public/favicon/gravatar.png&lt;/code&gt;（站点头像，需要放在 &lt;code&gt;public/&lt;/code&gt; 目录下以保证 URL 稳定）。&lt;/p&gt;
&lt;p&gt;构建后在浏览器开发者工具检查 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;，确认 &lt;code&gt;og:image&lt;/code&gt; 指向正确的图片 URL。&lt;/p&gt;
&lt;h1&gt;二、分享按钮：ShareButton.astro&lt;/h1&gt;
&lt;h2&gt;2.1 新建组件&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/components/ShareButton.astro&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import { Icon } from &quot;astro-icon/components&quot;;

interface Props {
    title: string;
    url: string;
    class?: string;
}

const { title, url } = Astro.props;
const className = Astro.props.class || &quot;&quot;;
---

&amp;lt;div class:list={[&quot;share-btn-container inline-flex items-center&quot;, className]}&amp;gt;
    &amp;lt;button
        class=&quot;share-btn btn-plain rounded-lg h-9 w-9 flex items-center justify-center&quot;
        data-title={title}
        data-url={url}
        onclick=&quot;(async function(btn){var url=btn.dataset.url;try{if(navigator.share){await navigator.share({title:btn.dataset.title,url:url})}else{await navigator.clipboard.writeText(url);btn.classList.add(&apos;success&apos;);clearTimeout(btn._t);btn._t=setTimeout(function(){btn.classList.remove(&apos;success&apos;)},1500)}}catch(e){}})(this)&quot;
    &amp;gt;
        &amp;lt;Icon name=&quot;fa6-solid:arrow-up-from-bracket&quot; class=&quot;share-btn-icon text-[1.1rem]&quot; /&amp;gt;
        &amp;lt;Icon name=&quot;material-symbols:check-rounded&quot; class=&quot;share-btn-success-icon text-[1.1rem]&quot; /&amp;gt;
    &amp;lt;/button&amp;gt;
    &amp;lt;span class=&quot;share-btn-text text-xs text-[var(--primary)] whitespace-nowrap&quot;&amp;gt;已复制&amp;lt;/span&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;style is:inline&amp;gt;
.share-btn-container { position: relative; display: inline-flex; align-items: center; gap: 0.375rem; }
.share-btn .share-btn-icon { display: inline; }
.share-btn .share-btn-success-icon { display: none; }
.share-btn.success .share-btn-icon { display: none; }
.share-btn.success .share-btn-success-icon { display: inline; }
.share-btn.success { color: var(--primary) !important; }
.share-btn-text { opacity: 0; transition: opacity 0.2s; }
.share-btn-container .share-btn.success ~ .share-btn-text { opacity: 1; }
&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.2 设计决策说明&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;为什么用内联 onclick 而不是 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; + addEventListener？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Swup 切页时会替换 &lt;code&gt;#swup-container&lt;/code&gt; 内的整个 DOM。如果用 &lt;code&gt;document.getElementById(&apos;share-btn&apos;).addEventListener(...)&lt;/code&gt; ，首次加载可以工作，但 Swup 导航到新页面后，旧的 DOM 节点被移除，新节点上的事件监听器不存在了。内联 &lt;code&gt;onclick&lt;/code&gt; 是 HTML 属性，随 DOM 一起渲染，天然兼容 Swup，不需要在 &lt;code&gt;swup.hooks.on(&apos;page:view&apos;, ...)&lt;/code&gt; 中重新绑定。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么用 &lt;code&gt;data-title&lt;/code&gt; / &lt;code&gt;data-url&lt;/code&gt; 属性？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Astro 的 &lt;code&gt;define:vars&lt;/code&gt; 可以把变量注入 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;，但我们的 onclick 已经是内联的，用 &lt;code&gt;data-*&lt;/code&gt; 属性更简洁，也避免了 HTML 属性中转义 JavaScript 的复杂性。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么 &lt;code&gt;&amp;lt;style is:inline&amp;gt;&lt;/code&gt;？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Astro 默认对 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; 做 scope 隔离（加 &lt;code&gt;data-astro-cid-xxx&lt;/code&gt; 选择器），但我们的 onclick 是原生 HTML 属性，不在 Astro 的 scope 内，scoped 样式无法匹配到。&lt;code&gt;is:inline&lt;/code&gt; 让样式成为全局 CSS，配合内联 onclick 正常工作。&lt;/p&gt;
&lt;h2&gt;2.3 按钮样式&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;使用 Fuwari 原生的 &lt;code&gt;btn-plain&lt;/code&gt; 类，自带 hover 变色、&lt;code&gt;expand-animation&lt;/code&gt; 涟漪效果&lt;/li&gt;
&lt;li&gt;&lt;code&gt;h-9 w-9&lt;/code&gt;（36px）与 Fuwari 的 &lt;code&gt;meta-icon&lt;/code&gt; 尺寸一致&lt;/li&gt;
&lt;li&gt;图标使用 &lt;code&gt;fa6-solid:arrow-up-from-bracket&lt;/code&gt;（分享箭头），复制成功后切换为 &lt;code&gt;material-symbols:check-rounded&lt;/code&gt;（勾号）&lt;/li&gt;
&lt;li&gt;&quot;已复制&quot; 文字通过 CSS 相邻兄弟选择器 &lt;code&gt;.share-btn.success ~ .share-btn-text&lt;/code&gt; 控制显隐，无需 JavaScript 操作&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2.4 点击行为&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;环境&lt;/th&gt;
&lt;th&gt;行为&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iOS / Android 浏览器&lt;/td&gt;
&lt;td&gt;调用 &lt;code&gt;navigator.share()&lt;/code&gt;，弹出系统原生分享面板（可选微信、Twitter、复制链接等）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;桌面浏览器（无 Web Share API）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;navigator.clipboard.writeText()&lt;/code&gt;，图标变勾号，显示&quot;已复制&quot;，1.5秒后恢复&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;clearTimeout(btn._t)&lt;/code&gt; 防止快速多次点击时 timeout 冲突。&lt;/p&gt;
&lt;h1&gt;三、集成到文章页面&lt;/h1&gt;
&lt;p&gt;编辑 &lt;code&gt;src/pages/posts/[...slug].astro&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;导入组件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import ShareButton from &quot;../../components/ShareButton.astro&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;获取文章 URL：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const postUrl = Astro.url.toString();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将 ShareButton 放在 PostMetadata 同行右侧：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div class=&quot;flex items-start justify-between mb-5&quot;&amp;gt;
    &amp;lt;PostMetadata class=&quot;flex-1 min-w-0&quot; ... /&amp;gt;
    &amp;lt;ShareButton title={entry.data.title} url={postUrl} class=&quot;ml-4 shrink-0&quot; /&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;flex-1 min-w-0&lt;/code&gt; 让 PostMetadata 占满剩余空间，&lt;code&gt;min-w-0&lt;/code&gt; 防止内容过长时溢出&lt;/li&gt;
&lt;li&gt;&lt;code&gt;shrink-0&lt;/code&gt; 防止 ShareButton 被压缩&lt;/li&gt;
&lt;li&gt;&lt;code&gt;items-start&lt;/code&gt; 让按钮对齐行首，PostMetadata 换行时不影响按钮位置&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;四、验证&lt;/h1&gt;
&lt;p&gt;构建后检查：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;有封面图的文章：&lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 中 &lt;code&gt;og:image&lt;/code&gt; 指向文章封面图 URL&lt;/li&gt;
&lt;li&gt;无封面图的文章：&lt;code&gt;og:image&lt;/code&gt; 指向 fallback 头像 URL&lt;/li&gt;
&lt;li&gt;分享按钮出现在日期/分类/标签行右侧&lt;/li&gt;
&lt;li&gt;移动端点击：弹出系统分享面板&lt;/li&gt;
&lt;li&gt;桌面端点击：图标变勾号 + 显示&quot;已复制&quot;&lt;/li&gt;
&lt;li&gt;Swup 页面切换后，新页面的分享按钮功能正常&lt;/li&gt;
&lt;li&gt;亮色/暗色主题下按钮样式一致&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;文件清单&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;操作&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/layouts/Layout.astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;修改&lt;/td&gt;
&lt;td&gt;Props 加 &lt;code&gt;ogImage&lt;/code&gt;，添加 &lt;code&gt;og:image&lt;/code&gt; meta 标签&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/layouts/MainGridLayout.astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;修改&lt;/td&gt;
&lt;td&gt;透传 &lt;code&gt;ogImage&lt;/code&gt; 到 Layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/pages/posts/[...slug].astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;修改&lt;/td&gt;
&lt;td&gt;传入 &lt;code&gt;ogImage&lt;/code&gt; 和 &lt;code&gt;postUrl&lt;/code&gt;，插入 ShareButton&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/components/ShareButton.astro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;新建&lt;/td&gt;
&lt;td&gt;分享按钮组件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;public/favicon/gravatar.png&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;已有&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt; fallback 头像&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>Fuwari 文章反应功能</title><link>https://blog.yuer6327.top/posts/fuwari%E6%96%87%E7%AB%A0%E5%8F%8D%E5%BA%94%E5%8A%9F%E8%83%BD/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/fuwari%E6%96%87%E7%AB%A0%E5%8F%8D%E5%BA%94%E5%8A%9F%E8%83%BD/</guid><description>使用 Supabase + 原生 JS 为 Fuwari 静态博客添加表情反应按钮</description><pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在每篇文章底部添加一组表情反应按钮（👍❤️😮🎉🤔👀），点击即可投票，再次点击取消。数据存储在 Supabase 的免费 PostgreSQL 数据库中。&lt;/p&gt;
&lt;h1&gt;一、Supabase 数据库设置&lt;/h1&gt;
&lt;h2&gt;1. 创建项目&lt;/h2&gt;
&lt;p&gt;前往 &lt;a href=&quot;https://supabase.com&quot;&gt;Supabase&lt;/a&gt; 注册并创建一个免费项目，选择离你最近的区域。&lt;/p&gt;
&lt;h2&gt;2. 建表&lt;/h2&gt;
&lt;p&gt;进入项目的 SQL Editor，执行以下 SQL：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 反应表：每行记录一个用户对一篇文章的一种 emoji 投票
CREATE TABLE reactions (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    post_slug text NOT NULL,
    emoji text NOT NULL,
    fingerprint text NOT NULL,
    created_at timestamptz DEFAULT now(),
    -- 每个用户对每篇文章的每种 emoji 只能投一次
    CONSTRAINT reactions_post_slug_emoji_fingerprint_key
        UNIQUE (post_slug, emoji, fingerprint)
);

-- 按文章 slug 查询的索引
CREATE INDEX idx_reactions_post_slug ON reactions (post_slug);

-- 启用行级安全策略
ALTER TABLE reactions ENABLE ROW LEVEL SECURITY;

-- 匿名用户可读（公开数据）
CREATE POLICY &quot;Allow public read&quot; ON reactions
    FOR SELECT USING (true);

-- 匿名用户可写入（投票）
CREATE POLICY &quot;Allow public insert&quot; ON reactions
    FOR INSERT WITH CHECK (true);

-- 匿名用户可删除自己的投票（取消投票）
CREATE POLICY &quot;Allow public delete own&quot; ON reactions
    FOR DELETE USING (true);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 获取凭据&lt;/h2&gt;
&lt;p&gt;进入 Supabase 控制台 → Settings → API，记录两个值：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Project URL&lt;/strong&gt;：形如 &lt;code&gt;https://xxx.supabase.co&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;anon public key&lt;/strong&gt;：以 &lt;code&gt;eyJ&lt;/code&gt; 开头的长字符串&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;将它们写入项目根目录的 &lt;code&gt;.env&lt;/code&gt; 文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PUBLIC_SUPABASE_URL=https://xxx.supabase.co
PUBLIC_SUPABASE_ANON_KEY=eyJ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果部署在 Cloudflare Pages，需要在 Pages → Settings → Environment variables 中同步添加这两个变量。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二、创建 Reactions 组件&lt;/h1&gt;
&lt;p&gt;新建 &lt;code&gt;src/components/Reactions.astro&lt;/code&gt;。这是一个纯静态组件，只负责渲染 HTML 骨架和样式，不包含任何 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; —— 交互逻辑统一由 Layout.astro 管理。&lt;/p&gt;
&lt;p&gt;这种模式和项目中的 MathGraph、Twikoo 一致：组件输出静态 HTML，Layout.astro 的全局脚本通过 swup 的 &lt;code&gt;page:view&lt;/code&gt; 钩子负责初始化。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
interface Props {
    slug: string;
}
const { slug } = Astro.props;
---

&amp;lt;div id=&quot;post-reactions&quot; class=&quot;post-reactions onload-animation&quot;
     data-post-slug={slug} data-initialized=&quot;false&quot;&amp;gt;
    &amp;lt;div class=&quot;reactions-divider&quot;&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;div class=&quot;reactions-label&quot;&amp;gt;这篇文章对你有帮助吗？&amp;lt;/div&amp;gt;
    &amp;lt;div class=&quot;reactions-container&quot;&amp;gt;
        &amp;lt;button class=&quot;reaction-btn&quot; data-emoji=&quot;👍&quot; disabled&amp;gt;
            &amp;lt;span class=&quot;reaction-emoji&quot;&amp;gt;👍&amp;lt;/span&amp;gt;
            &amp;lt;span class=&quot;reaction-count&quot;&amp;gt;·&amp;lt;/span&amp;gt;
        &amp;lt;/button&amp;gt;
        &amp;lt;button class=&quot;reaction-btn&quot; data-emoji=&quot;❤️&quot; disabled&amp;gt;
            &amp;lt;span class=&quot;reaction-emoji&quot;&amp;gt;❤️&amp;lt;/span&amp;gt;
            &amp;lt;span class=&quot;reaction-count&quot;&amp;gt;·&amp;lt;/span&amp;gt;
        &amp;lt;/button&amp;gt;
        &amp;lt;button class=&quot;reaction-btn&quot; data-emoji=&quot;😮&quot; disabled&amp;gt;
            &amp;lt;span class=&quot;reaction-emoji&quot;&amp;gt;😮&amp;lt;/span&amp;gt;
            &amp;lt;span class=&quot;reaction-count&quot;&amp;gt;·&amp;lt;/span&amp;gt;
        &amp;lt;/button&amp;gt;
        &amp;lt;button class=&quot;reaction-btn&quot; data-emoji=&quot;🎉&quot; disabled&amp;gt;
            &amp;lt;span class=&quot;reaction-emoji&quot;&amp;gt;🎉&amp;lt;/span&amp;gt;
            &amp;lt;span class=&quot;reaction-count&quot;&amp;gt;·&amp;lt;/span&amp;gt;
        &amp;lt;/button&amp;gt;
        &amp;lt;button class=&quot;reaction-btn&quot; data-emoji=&quot;🤔&quot; disabled&amp;gt;
            &amp;lt;span class=&quot;reaction-emoji&quot;&amp;gt;🤔&amp;lt;/span&amp;gt;
            &amp;lt;span class=&quot;reaction-count&quot;&amp;gt;·&amp;lt;/span&amp;gt;
        &amp;lt;/button&amp;gt;
        &amp;lt;button class=&quot;reaction-btn&quot; data-emoji=&quot;👀&quot; disabled&amp;gt;
            &amp;lt;span class=&quot;reaction-emoji&quot;&amp;gt;👀&amp;lt;/span&amp;gt;
            &amp;lt;span class=&quot;reaction-count&quot;&amp;gt;·&amp;lt;/span&amp;gt;
        &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;style is:inline&amp;gt;
.post-reactions {
    padding-top: 1rem;
    padding-bottom: 0.5rem;
}
.reactions-divider {
    border-bottom: 1px dashed var(--line-divider);
    margin-bottom: 1.25rem;
}
.reactions-label {
    font-size: 0.875rem;
    color: rgba(0, 0, 0, 0.5);
    margin-bottom: 0.75rem;
}
html.dark .reactions-label {
    color: rgba(255, 255, 255, 0.5);
}
.reactions-container {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}
.reaction-btn {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.375rem 0.75rem;
    border-radius: 9999px;
    border: 2px solid var(--btn-regular-bg);
    background: transparent;
    cursor: pointer;
    transition: all 0.2s;
    font-size: 0.875rem;
    user-select: none;
    color: inherit;
}
.reaction-btn:hover:not(:disabled):not(.voted):not(.pending) {
    background: var(--btn-plain-bg-hover);
    border-color: var(--primary);
    color: var(--primary);
}
.reaction-btn:disabled {
    opacity: 0.5;
    cursor: default;
}
.reaction-btn.pending {
    pointer-events: none;
}
.reaction-btn.voted {
    background: var(--primary);
    color: white;
    border-color: var(--primary);
}
.reaction-btn.voted:hover:not(.pending) {
    opacity: 0.85;
}
.reaction-btn:active:not(:disabled):not(.pending) {
    transform: scale(0.95);
}
.reaction-count {
    font-size: 0.75rem;
    color: rgba(0, 0, 0, 0.5);
    font-variant-numeric: tabular-nums;
    min-width: 0.5rem;
    text-align: center;
}
html.dark .reaction-count {
    color: rgba(255, 255, 255, 0.5);
}
.reaction-btn.voted .reaction-count {
    color: white;
}
&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;要点说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;按钮初始状态为 &lt;code&gt;disabled&lt;/code&gt;，计数显示 &lt;code&gt;·&lt;/code&gt; 占位符，等 JS 加载数据后替换&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data-post-slug&lt;/code&gt; 存储文章标识（不使用完整 URL，避免 URL 结构变化导致数据丢失）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data-initialized&lt;/code&gt; 用于防止 swup 切页时重复初始化&lt;/li&gt;
&lt;li&gt;样式使用 &lt;code&gt;&amp;lt;style is:inline&amp;gt;&lt;/code&gt; 而非 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;，因为 Astro 的 scoped style 会添加属性选择器，与动态 DOM 操作不兼容&lt;/li&gt;
&lt;li&gt;颜色全部引用 Fuwari 的 CSS 变量（&lt;code&gt;--primary&lt;/code&gt;、&lt;code&gt;--btn-regular-bg&lt;/code&gt;、&lt;code&gt;--btn-plain-bg-hover&lt;/code&gt;），自动适配亮/暗主题&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;三、在文章页放置组件&lt;/h1&gt;
&lt;p&gt;编辑 &lt;code&gt;src/pages/posts/[...slug].astro&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在 frontmatter 中添加 import：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import Reactions from &quot;../../components/Reactions.astro&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;#post-container&lt;/code&gt; 内部、License 组件之后放置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{licenseConfig.enable &amp;amp;&amp;amp; &amp;lt;License ...&amp;gt;&amp;lt;/License&amp;gt;}

&amp;lt;Reactions slug={getEntrySlug(entry)} /&amp;gt;

&amp;lt;/div&amp;gt;  &amp;lt;!-- #post-container 的关闭标签 --&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;四、Layout.astro 添加交互逻辑&lt;/h1&gt;
&lt;p&gt;这是改动最多的文件，分为三步。&lt;/p&gt;
&lt;h2&gt;1. 注入 Supabase 凭据&lt;/h2&gt;
&lt;p&gt;在 Layout.astro 的 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 区域，主题加载脚本之后添加一个 &lt;code&gt;&amp;lt;script is:inline define:vars&amp;gt;&lt;/code&gt; 块，将 &lt;code&gt;.env&lt;/code&gt; 中的变量注入到 &lt;code&gt;window&lt;/code&gt; 对象上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script is:inline define:vars={{
    supabaseUrl: import.meta.env.PUBLIC_SUPABASE_URL,
    supabaseKey: import.meta.env.PUBLIC_SUPABASE_ANON_KEY
}}&amp;gt;
    window.__SUPABASE_URL = supabaseUrl;
    window.__SUPABASE_ANON_KEY = supabaseKey;
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里用 &lt;code&gt;define:vars&lt;/code&gt; 而不是在 JS 中硬编码，是因为 Astro 在构建时会把 &lt;code&gt;import.meta.env.PUBLIC_*&lt;/code&gt; 替换为实际值，同时密钥不会出现在源码仓库中。&lt;/p&gt;
&lt;h2&gt;2. 添加 &lt;code&gt;initReactions()&lt;/code&gt; 函数&lt;/h2&gt;
&lt;p&gt;在 Layout.astro 的主 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 块中（&lt;code&gt;initTwikoo()&lt;/code&gt; 函数之后），添加完整的反应功能逻辑。核心分为三个部分：&lt;/p&gt;
&lt;h3&gt;浏览器指纹生成&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;async function generateFingerprint() {
    const components = [
        navigator.userAgent,
        screen.width + &apos;x&apos; + screen.height,
        String(screen.colorDepth),
        String(new Date().getTimezoneOffset()),
        navigator.language,
        String(navigator.hardwareConcurrency || &apos;&apos;),
        String((navigator as any).deviceMemory || &apos;&apos;),
    ];
    // Canvas 指纹
    try {
        const canvas = document.createElement(&apos;canvas&apos;);
        canvas.width = 200;
        canvas.height = 50;
        const ctx = canvas.getContext(&apos;2d&apos;);
        if (ctx) {
            ctx.textBaseline = &apos;top&apos;;
            ctx.font = &apos;14px Arial&apos;;
            ctx.fillStyle = &apos;#f60&apos;;
            ctx.fillRect(125, 1, 62, 20);
            ctx.fillStyle = &apos;#069&apos;;
            ctx.fillText(&apos;fingerprint&apos;, 2, 15);
            components.push(canvas.toDataURL());
        }
    } catch (e) { /* ignore */ }
    // WebGL 渲染器信息
    try {
        const gl = document.createElement(&apos;canvas&apos;).getContext(&apos;webgl&apos;);
        if (gl) {
            const debugInfo = gl.getExtension(&apos;WEBGL_debug_renderer_info&apos;);
            if (debugInfo) {
                components.push(String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)));
            }
        }
    } catch (e) { /* ignore */ }
    const data = components.join(&apos;|||&apos;);
    const hashBuffer = await crypto.subtle.digest(&apos;SHA-256&apos;, new TextEncoder().encode(data));
    return Array.from(new Uint8Array(hashBuffer))
        .map(b =&amp;gt; b.toString(16).padStart(2, &apos;0&apos;))
        .join(&apos;&apos;);
}

async function getFingerprint() {
    let fp = localStorage.getItem(&apos;reactions_fp&apos;);
    if (!fp) {
        fp = await generateFingerprint();
        localStorage.setItem(&apos;reactions_fp&apos;, fp);
    }
    return fp;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原理：收集浏览器的 UA、屏幕分辨率、色深、时区、语言、CPU 核数、内存、Canvas 渲染结果、WebGL 渲染器名称，拼接后做 SHA-256 哈希。同一浏览器（相同配置和硬件）会生成相同的哈希值，结果缓存在 localStorage 中只需计算一次。&lt;/p&gt;
&lt;p&gt;这个指纹不是密码学级别的身份标识，但足以在&quot;无登录&quot;场景下起到基本的防重复投票作用。无痕模式下 localStorage 隔离，指纹自然独立。&lt;/p&gt;
&lt;h3&gt;数据获取与渲染&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;async function initReactions() {
    const container = document.getElementById(&apos;post-reactions&apos;);
    if (!container || container.dataset.initialized === &apos;true&apos;) return;

    // 用 AbortController 取消上一次未完成的请求（应对快速切页）
    const existingController = (container as any).__abortController;
    if (existingController) existingController.abort();
    const controller = new AbortController();
    (container as any).__abortController = controller;

    const slug = container.dataset.postSlug;
    if (!slug) return;

    const supabaseUrl = (window as any).__SUPABASE_URL;
    const supabaseKey = (window as any).__SUPABASE_ANON_KEY;
    if (!supabaseUrl || !supabaseKey) return;

    container.dataset.initialized = &apos;true&apos;;

    try {
        const fingerprint = await getFingerprint();

        // 一次请求获取该文章的所有反应记录
        const resp = await fetch(
            `${supabaseUrl}/rest/v1/reactions?post_slug=eq.${encodeURIComponent(slug)}&amp;amp;select=emoji,fingerprint`,
            {
                headers: {
                    &apos;apikey&apos;: supabaseKey,
                    &apos;Authorization&apos;: `Bearer ${supabaseKey}`,
                },
                signal: controller.signal,
            }
        );

        if (!resp.ok) return;
        const rows = await resp.json();

        // 客户端统计计数（数据量极小，无需服务端聚合）
        const counts = {};
        const userVotes = new Set();
        for (const row of rows) {
            counts[row.emoji] = (counts[row.emoji] || 0) + 1;
            if (row.fingerprint === fingerprint) {
                userVotes.add(row.emoji);
            }
        }

        // 启用按钮、显示计数、标记已投票状态
        const buttons = container.querySelectorAll(&apos;.reaction-btn&apos;);
        buttons.forEach(btn =&amp;gt; {
            const emoji = btn.dataset.emoji;
            btn.disabled = false;
            const countEl = btn.querySelector(&apos;.reaction-count&apos;);
            countEl.textContent = String(counts[emoji] || 0);

            if (userVotes.has(emoji)) {
                btn.classList.add(&apos;voted&apos;);
            }

            // 绑定点击事件（见下文）
            btn.addEventListener(&apos;click&apos;, () =&amp;gt; handleClick(/* ... */));
        });
    } catch (e) {
        if (e.name === &apos;AbortError&apos;) return;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用 PostgREST 的过滤语法 &lt;code&gt;?post_slug=eq.xxx&amp;amp;select=emoji,fingerprint&lt;/code&gt; 一次性拿到该文章的全部记录，客户端做分组统计。对博客文章来说数据量极小（每篇文章最多 6 种 emoji），不需要服务端聚合。&lt;/p&gt;
&lt;h3&gt;点击事件：乐观更新 + 切换投票&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;btn.addEventListener(&apos;click&apos;, async function handleClick() {
    if (btn.classList.contains(&apos;pending&apos;)) return;
    btn.classList.add(&apos;pending&apos;);  // 防快速重复点击

    const cache = JSON.parse(localStorage.getItem(&apos;reactions_votes&apos;) || &apos;{}&apos;);
    const alreadyVoted = cache[slug] &amp;amp;&amp;amp; cache[slug][emoji];
    const adding = !alreadyVoted;  // 切换方向

    // 乐观更新 UI（先改界面，再发请求）
    btn.classList.toggle(&apos;voted&apos;, adding);
    const prevText = countEl.textContent;
    const current = parseInt(prevText) || 0;
    countEl.textContent = String(Math.max(0, adding ? current + 1 : current - 1));

    try {
        let ok;
        if (adding) {
            // 投票：POST 新记录
            const r = await fetch(`${supabaseUrl}/rest/v1/reactions`, {
                method: &apos;POST&apos;,
                headers: {
                    &apos;apikey&apos;: supabaseKey,
                    &apos;Authorization&apos;: `Bearer ${supabaseKey}`,
                    &apos;Content-Type&apos;: &apos;application/json&apos;,
                    &apos;Prefer&apos;: &apos;return=minimal&apos;,
                },
                body: JSON.stringify({ post_slug: slug, emoji, fingerprint }),
                signal: controller.signal,
            });
            ok = r.ok || r.status === 409;  // 409 = 唯一约束冲突，视为成功
        } else {
            // 取消投票：DELETE 匹配记录
            const r = await fetch(
                `${supabaseUrl}/rest/v1/reactions?post_slug=eq.${encodeURIComponent(slug)}&amp;amp;emoji=eq.${encodeURIComponent(emoji)}&amp;amp;fingerprint=eq.${encodeURIComponent(fingerprint)}`,
                {
                    method: &apos;DELETE&apos;,
                    headers: {
                        &apos;apikey&apos;: supabaseKey,
                        &apos;Authorization&apos;: `Bearer ${supabaseKey}`,
                    },
                    signal: controller.signal,
                }
            );
            ok = r.ok;
        }

        if (ok) {
            // 更新本地缓存
            if (!cache[slug]) cache[slug] = {};
            if (adding) cache[slug][emoji] = true;
            else delete cache[slug][emoji];
            localStorage.setItem(&apos;reactions_votes&apos;, JSON.stringify(cache));
        } else {
            // 请求失败，回滚 UI
            btn.classList.toggle(&apos;voted&apos;, !adding);
            countEl.textContent = prevText;
        }
    } catch (e) {
        if (e.name === &apos;AbortError&apos;) return;
        btn.classList.toggle(&apos;voted&apos;, !adding);
        countEl.textContent = prevText;
    } finally {
        btn.classList.remove(&apos;pending&apos;);
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个设计要点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;乐观更新&lt;/strong&gt;：先改 UI 再发请求。网络请求可能需要几百毫秒甚至更长，如果等到响应回来再改界面，用户会感到明显延迟。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;切换投票&lt;/strong&gt;：已投票时再点会发送 DELETE 请求删除对应行，同时 UI 回退。&lt;code&gt;localStorage&lt;/code&gt; 缓存记录每个 slug+emoji 的投票状态，避免每次都要查数据库。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;防快速点击&lt;/strong&gt;：请求期间给按钮添加 &lt;code&gt;pending&lt;/code&gt; 类（&lt;code&gt;pointer-events: none&lt;/code&gt;），阻止重复提交。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. 注册调用点&lt;/h2&gt;
&lt;p&gt;在 Layout.astro 中，&lt;code&gt;initReactions()&lt;/code&gt; 需要在两个地方被调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 初始页面加载
function init() {
    loadTheme();
    loadHue();
    setTimeout(() =&amp;gt; { initTwikoo() }, 100)
    initReactions()  // ← 添加
}

// swup 切页后
window.swup.hooks.on(&apos;page:view&apos;, () =&amp;gt; {
    // ... 其他初始化 ...
    setTimeout(() =&amp;gt; { initTwikoo() }, 100)
    initReactions()  // ← 添加
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;swup 切页时会替换 &lt;code&gt;#swup-container&lt;/code&gt; 内的全部 HTML，新页面的 &lt;code&gt;#post-reactions&lt;/code&gt; 元素带有 &lt;code&gt;data-initialized=&quot;false&quot;&lt;/code&gt;，&lt;code&gt;initReactions()&lt;/code&gt; 会检测到这个状态并重新执行初始化。旧页面的 &lt;code&gt;AbortController&lt;/code&gt; 被 abort，防止过期请求更新错误的 DOM。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;五、动画延迟&lt;/h1&gt;
&lt;p&gt;编辑 &lt;code&gt;src/styles/transition.css&lt;/code&gt;，在 &lt;code&gt;#post-container :nth-child(6)&lt;/code&gt; 之后添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#post-container :nth-child(7) { animation-delay: calc(var(--content-delay) + 400ms) }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fuwari 的文章内容区通过 &lt;code&gt;nth-child&lt;/code&gt; 逐子元素设置递增的入场动画延迟，Reactions 作为第 7 个子元素需要补上对应的延迟，否则会没有渐入效果。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;六、防刷策略总结&lt;/h1&gt;
&lt;p&gt;本方案采用三层防护，不依赖用户登录：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层级&lt;/th&gt;
&lt;th&gt;机制&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;客户端&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localStorage&lt;/code&gt; 投票缓存&lt;/td&gt;
&lt;td&gt;同一浏览器即时阻止重复投票，无需查库&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;客户端&lt;/td&gt;
&lt;td&gt;浏览器指纹 SHA-256&lt;/td&gt;
&lt;td&gt;跨页面、跨 session 识别同一用户&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据库&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UNIQUE (post_slug, emoji, fingerprint)&lt;/code&gt; 约束&lt;/td&gt;
&lt;td&gt;即使绕过前端，数据库也会拒绝重复插入&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;客户端指纹不是密码学身份标识，清除 localStorage 或换浏览器就能绕过。对于个人博客来说这个防护等级足够。如果需要更严格的限制，可以在 Supabase Edge Function 中添加基于 IP 的频率限制，但需要额外配置。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;七、注意事项&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;Supabase 的 anon key 设计为公开使用，配合 RLS 策略不会暴露数据。&lt;strong&gt;不要&lt;/strong&gt;使用 service_role key，那个有完全数据库权限。&lt;/li&gt;
&lt;li&gt;确保 &lt;code&gt;.env&lt;/code&gt; 中的 &lt;code&gt;PUBLIC_SUPABASE_URL&lt;/code&gt; &lt;strong&gt;不包含&lt;/strong&gt; &lt;code&gt;/rest/v1/&lt;/code&gt; 后缀，代码会自动拼接这个路径。&lt;/li&gt;
&lt;li&gt;如果部署在 Cloudflare Pages，需要在 Pages 的环境变量设置中同步添加 Supabase 凭据，部署后重新构建。&lt;/li&gt;
&lt;li&gt;Supabase 免费项目在 7 天无活动后会自动暂停，首次访问会有一两秒的冷启动延迟。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Site Bot</title><link>https://blog.yuer6327.top/posts/site-bot/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/site-bot/</guid><description>A site bot based on Openclaw</description><pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;正好前段时间薅到了元宝的 Bot ，拿来试试手&lt;br /&gt;
尝试在评论区&lt;code&gt;@Bot&lt;/code&gt;，机器人看到就会回复！&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;2026-04-30更新：
实在太费token了😂，还是停机了吧。&lt;/p&gt;
</content:encoded></item><item><title>Demo</title><link>https://blog.yuer6327.top/posts/demo/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/demo/</guid><description>一些博客站的功能演示</description><pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Mermaid 图表&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;pie title 月度支出分布
    &quot;餐饮&quot; : 35
    &quot;交通&quot; : 15
    &quot;娱乐&quot; : 20
    &quot;学习&quot; : 20
    &quot;其他&quot; : 10
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;mindmap
  root((周末计划))
    工作
      写周报
      回复邮件
    生活
      健身房
      超市购物
    娱乐
      看电影
      朋友聚会
    学习
      阅读 1 小时
      练习英语
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;xychart-beta
    title &quot;2025 下半年月度数据对比&quot;
    x-axis [&quot;7月&quot;, &quot;8月&quot;, &quot;9月&quot;, &quot;10月&quot;, &quot;11月&quot;, &quot;12月&quot;]
    y-axis &quot;数值&quot; 0 --&amp;gt; 100
    bar [45, 60, 55, 70, 65, 80]
    line [30, 40, 50, 55, 70, 85]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    A[开始] --&amp;gt; B{是否完成？}
    B --&amp;gt;|是| C[✅ 结束]
    B --&amp;gt;|否| D[🔄 继续执行]
    D --&amp;gt; B
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;数学图形交互演示&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;你可以通过滑动条实时调整参数，观察函数图像的变化。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;基础示例：二次函数&lt;/h2&gt;
&lt;p&gt;让我们从一个简单的二次函数开始：$f(x) = ax^2 + bx + c$&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sliders:
  a: [-3, 1, 3, 0.1]
  b: [-5, 0, 5, 0.1]
  c: [-5, 0, 5, 0.1]
expressions:
  - a*x*x + b*x + c
axis: true
grid: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过调整参数 $a$、$b$、$c$，你可以观察到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$a$ 控制抛物线的开口方向和宽度&lt;/li&gt;
&lt;li&gt;$b$ 控制抛物线的对称轴位置&lt;/li&gt;
&lt;li&gt;$c$ 控制抛物线的上下平移&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;三角函数组合&lt;/h2&gt;
&lt;p&gt;让我们看看三角函数的组合效果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sliders:
  a: [0.1, 1, 3, 0.1]
  b: [0.1, 1, 5, 0.1]
  c: [-3.14, 0, 3.14, 0.1]
expressions:
  - a*sin(b*x + c)
  - a*cos(b*x + c)
axis: true
grid: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里展示了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;正弦函数 $\sin(x)$ 和余弦函数 $\cos(x)$&lt;/li&gt;
&lt;li&gt;参数 $a$ 控制振幅&lt;/li&gt;
&lt;li&gt;参数 $b$ 控制频率&lt;/li&gt;
&lt;li&gt;参数 $c$ 控制相位&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;指数函数与对数函数&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sliders:
  a: [0.5, 2, 4, 0.1]
  b: [0.1, 1, 2, 0.1]
expressions:
  - a*exp(b*x)
  - a*log(b*x + 1)
axis: true
grid: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;多项式函数&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sliders:
  a: [-2, 1, 2, 0.1]
  b: [-3, 0, 3, 0.1]
  c: [-2, 0, 2, 0.1]
expressions:
  - x*x*x + a*x*x + b*x + c
axis: true
grid: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;使用说明&lt;/h2&gt;
&lt;p&gt;在 Markdown 中使用以下语法创建交互式数学图形：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;```mathgraph
sliders:
  参数名: [最小值, 默认值, 最大值, 步长]
expressions:
  - 数学表达式
axis: true/false
grid: true/false
```
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;支持的数学函数&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;基本运算：&lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;^&lt;/code&gt;, &lt;code&gt;%&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;三角函数：&lt;code&gt;sin(x)&lt;/code&gt;, &lt;code&gt;cos(x)&lt;/code&gt;, &lt;code&gt;tan(x)&lt;/code&gt;, &lt;code&gt;asin(x)&lt;/code&gt;, &lt;code&gt;acos(x)&lt;/code&gt;, &lt;code&gt;atan(x)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;指数对数：&lt;code&gt;exp(x)&lt;/code&gt;, &lt;code&gt;log(x)&lt;/code&gt;, &lt;code&gt;log10(x)&lt;/code&gt;, &lt;code&gt;sqrt(x)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;其他：&lt;code&gt;abs(x)&lt;/code&gt;, &lt;code&gt;floor(x)&lt;/code&gt;, &lt;code&gt;ceil(x)&lt;/code&gt;, &lt;code&gt;round(x)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;注意事项&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;参数名只能使用字母和数字&lt;/li&gt;
&lt;li&gt;表达式中使用 &lt;code&gt;x&lt;/code&gt; 作为自变量&lt;/li&gt;
&lt;li&gt;滑动条参数格式：&lt;code&gt;[最小值, 默认值, 最大值, 步长]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;可以同时绘制多条曲线&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>数学图像功能实现教程</title><link>https://blog.yuer6327.top/posts/%E6%95%B0%E5%AD%A6%E5%9B%BE%E5%BD%A2%E5%8A%9F%E8%83%BD%E5%AE%9E%E7%8E%B0%E6%95%99%E7%A8%8B/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/%E6%95%B0%E5%AD%A6%E5%9B%BE%E5%BD%A2%E5%8A%9F%E8%83%BD%E5%AE%9E%E7%8E%B0%E6%95%99%E7%A8%8B/</guid><description>为 Fuwari 博客添加交互式数学图形显示功能，支持滑动条实时调整参数</description><pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;功能预览&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;../demo/#%E6%95%B0%E5%AD%A6%E5%9B%BE%E5%BD%A2%E4%BA%A4%E4%BA%92%E6%BC%94%E7%A4%BA&quot;&gt;Demo&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ 在 Markdown 中使用 &lt;code&gt;mathgraph&lt;/code&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;✅ 自动适配 Fuwari 主题&lt;/li&gt;
&lt;li&gt;✅ 支持亮色/暗色模式&lt;/li&gt;
&lt;li&gt;✅ 移动端响应式设计&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::note
所有代码都是AI敲的，所以相关问题不要问我
:::&lt;/p&gt;
&lt;h2&gt;实现步骤&lt;/h2&gt;
&lt;h3&gt;步骤 1: 下载 JSXGraph 库到本地&lt;/h3&gt;
&lt;p&gt;首先，我们需要将 JSXGraph 库下载到本地，避免远程调用可能遇到的网络问题。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 创建目录
mkdir -p public/jsxgraph

# 下载 JSXGraph 核心库
curl -o public/jsxgraph/jsxgraphcore.js https://cdn.jsdelivr.net/npm/jsxgraph@1.8.0/distrib/jsxgraphcore.js

# 下载 JSXGraph 样式
curl -o public/jsxgraph/jsxgraph.css https://cdn.jsdelivr.net/npm/jsxgraph@1.8.0/distrib/jsxgraph.css
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;public/&lt;/code&gt; 目录下的文件会被直接复制到构建输出&lt;/li&gt;
&lt;li&gt;使用本地文件避免依赖远程 CDN&lt;/li&gt;
&lt;li&gt;确保部署到任何平台都能正常工作&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;步骤 2: 创建 Remark 插件&lt;/h3&gt;
&lt;p&gt;创建 &lt;code&gt;src/plugins/remark-mathgraph.js&lt;/code&gt; 文件，用于解析 Markdown 中的 &lt;code&gt;mathgraph&lt;/code&gt; 代码块。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { visit } from &quot;unist-util-visit&quot;;

/**
 * Remark plugin to detect and transform mathgraph code blocks.
 * Converts ```mathgraph code blocks to interactive math graph components.
 */
export function remarkMathGraph() {
  return (tree) =&amp;gt; {
    visit(tree, &quot;code&quot;, (node) =&amp;gt; {
      if (node.lang === &quot;mathgraph&quot;) {
        const config = parseMathGraphConfig(node.value || &quot;&quot;);
        const configJson = JSON.stringify(config);
        const graphId = `mathgraph-${Math.random().toString(36).substr(2, 9)}`;
        
        // 创建完整的 HTML 结构
        node.data = {
          hName: &quot;div&quot;,
          hProperties: {
            className: [&quot;math-graph-container&quot;],
          },
          hChildren: [
            {
              type: &quot;element&quot;,
              tagName: &quot;div&quot;,
              properties: {
                className: [&quot;math-graph-wrapper&quot;],
              },
              children: [
                {
                  type: &quot;element&quot;,
                  tagName: &quot;div&quot;,
                  properties: {
                    className: [&quot;math-graph-canvas&quot;],
                    id: graphId,
                    &quot;data-config&quot;: configJson,
                  },
                  children: [],
                },
              ],
            },
          ],
        };
      }
    });
  };
}

function parseMathGraphConfig(content) {
  const config = {
    sliders: [],
    expressions: [],
    axis: true,
    grid: true,
  };
  
  const lines = content.split(&quot;\n&quot;);
  let currentSection = &quot;&quot;;
  
  for (const line of lines) {
    const trimmed = line.trim();
    
    // 跳过空行和注释
    if (!trimmed || trimmed.startsWith(&quot;#&quot;)) continue;
    
    // 检测区块
    if (trimmed === &quot;sliders:&quot;) {
      currentSection = &quot;sliders&quot;;
      continue;
    }
    if (trimmed === &quot;expressions:&quot;) {
      currentSection = &quot;expressions&quot;;
      continue;
    }
    
    // 解析滑动条配置
    if (currentSection === &quot;sliders&quot;) {
      const sliderMatch = trimmed.match(/^(\w+):\s*\[([^\]]+)\]$/);
      if (sliderMatch) {
        const name = sliderMatch[1];
        const values = sliderMatch[2].split(&quot;,&quot;).map((v) =&amp;gt; parseFloat(v.trim()));
        if (values.length &amp;gt;= 3) {
          config.sliders.push({
            name,
            min: values[0],
            value: values[1],
            max: values[2],
            step: values[3] || 0.1,
          });
        }
      }
    }
    
    // 解析表达式
    if (currentSection === &quot;expressions&quot;) {
      const exprMatch = trimmed.match(/^-\s*(.+)$/);
      if (exprMatch) {
        config.expressions.push(exprMatch[1].trim());
      }
    }
    
    // 解析其他配置
    if (trimmed.startsWith(&quot;axis:&quot;)) {
      config.axis = trimmed.split(&quot;:&quot;)[1].trim() === &quot;true&quot;;
    }
    if (trimmed.startsWith(&quot;grid:&quot;)) {
      config.grid = trimmed.split(&quot;:&quot;)[1].trim() === &quot;true&quot;;
    }
  }
  
  return config;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;代码说明&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;使用 &lt;code&gt;unist-util-visit&lt;/code&gt; 遍历 Markdown AST&lt;/li&gt;
&lt;li&gt;检测 &lt;code&gt;lang === &quot;mathgraph&quot;&lt;/code&gt; 的代码块&lt;/li&gt;
&lt;li&gt;解析配置内容（滑动条、表达式等）&lt;/li&gt;
&lt;li&gt;生成 HTML 结构，包含配置数据&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;步骤 3: 注册 Remark 插件&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;astro.config.mjs&lt;/code&gt; 中注册插件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { remarkMathGraph } from &quot;./src/plugins/remark-mathgraph.js&quot;;

export default defineConfig({
  // ... 其他配置
  markdown: {
    remarkPlugins: [
      // ... 其他插件
      remarkMathGraph,
    ],
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;步骤 4: 添加样式&lt;/h3&gt;
&lt;p&gt;创建 &lt;code&gt;src/styles/mathgraph.css&lt;/code&gt; 文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* MathGraph 样式 - 左图右参数布局 */

.math-graph-container {
  margin: 1.5rem 0;
  border-radius: 0.75rem;
  overflow: hidden;
  background: var(--card-bg);
  border: 1px solid var(--line-divider);
}

/* 左图右参数布局 */
.math-graph-layout {
  display: flex;
  flex-direction: row;
  gap: 0;
  min-height: 400px;
}

.math-graph-canvas-wrapper {
  flex: 1;
  min-width: 0;
  position: relative;
}

.math-graph-canvas {
  width: 100%;
  height: 400px;
  background: var(--page-bg);
}

/* 参数控制面板 */
.math-graph-controls {
  width: 280px;
  min-width: 280px;
  background: var(--btn-regular-bg);
  border-left: 1px solid var(--line-divider);
  display: flex;
  flex-direction: column;
}

.math-graph-controls-title {
  padding: 1rem;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--btn-content);
  border-bottom: 1px solid var(--line-divider);
  text-align: center;
}

/* 滑动条样式 */
.math-slider-wrapper {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.75rem;
  background: var(--card-bg);
  border-radius: 0.5rem;
  border: 1px solid var(--line-divider);
}

.math-slider-label {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--btn-content);
  display: flex;
  justify-content: space-between;
}

.math-slider-value {
  color: var(--primary);
}

.math-slider-input {
  width: 100%;
  height: 6px;
  border-radius: 3px;
  background: var(--btn-plain-bg-hover);
  outline: none;
  -webkit-appearance: none;
  cursor: pointer;
}

.math-slider-input::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--primary);
  cursor: pointer;
}

/* 响应式设计 */
@media (max-width: 768px) {
  .math-graph-layout {
    flex-direction: column;
  }
  
  .math-graph-controls {
    width: 100%;
    border-left: none;
    border-top: 1px solid var(--line-divider);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;样式要点&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用 Fuwari 的 CSS 变量（&lt;code&gt;--card-bg&lt;/code&gt;, &lt;code&gt;--primary&lt;/code&gt; 等）&lt;/li&gt;
&lt;li&gt;左图右参数布局（桌面端）&lt;/li&gt;
&lt;li&gt;移动端自动切换为上下布局&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;步骤 5: 在 Layout 中引入样式和脚本&lt;/h3&gt;
&lt;p&gt;编辑 &lt;code&gt;src/layouts/Layout.astro&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
// 在文件顶部引入样式
import &quot;../styles/mathgraph.css&quot;;
---

&amp;lt;head&amp;gt;
  &amp;lt;!-- 其他 head 内容 --&amp;gt;
  
  &amp;lt;!-- JSXGraph 库 --&amp;gt;
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;/jsxgraph/jsxgraph.css&quot; /&amp;gt;
  &amp;lt;script src=&quot;/jsxgraph/jsxgraphcore.js&quot; defer&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;步骤 6: 添加初始化脚本&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;src/layouts/Layout.astro&lt;/code&gt; 的 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 标签中添加初始化代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ========== 数学图形初始化 ==========
let mathGraphObserver = null;

function initMathGraphs() {
  const containers = document.querySelectorAll(
    &apos;.math-graph-canvas[data-config]:not([data-initialized])&apos;
  );
  
  if (containers.length === 0) return;
  
  // 检查 JSXGraph 是否已加载
  if (!window.JXG) {
    waitForJSXGraph().then(initMathGraphs);
    return;
  }
  
  containers.forEach((container) =&amp;gt; {
    const configStr = container.getAttribute(&apos;data-config&apos;);
    if (!configStr) return;
    
    try {
      const config = JSON.parse(configStr);
      createMathGraph(container, config);
      container.setAttribute(&apos;data-initialized&apos;, &apos;true&apos;);
    } catch (e) {
      console.error(&apos;MathGraph config parse error:&apos;, e);
    }
  });
}

// 使用 MutationObserver 监听 DOM 变化
function observeMathGraphs() {
  if (mathGraphObserver) {
    mathGraphObserver.disconnect();
  }
  
  initMathGraphs();
  
  mathGraphObserver = new MutationObserver((mutations) =&amp;gt; {
    let shouldInit = false;
    
    mutations.forEach((mutation) =&amp;gt; {
      mutation.addedNodes.forEach((node) =&amp;gt; {
        if (node instanceof Element) {
          if (node.classList?.contains(&apos;math-graph-canvas&apos;) || 
              node.querySelector?.(&apos;.math-graph-canvas&apos;)) {
            shouldInit = true;
          }
        }
      });
    });
    
    if (shouldInit) {
      setTimeout(initMathGraphs, 100);
    }
  });
  
  mathGraphObserver.observe(document.body, {
    childList: true,
    subtree: true,
  });
}

// 等待 JSXGraph 加载
function waitForJSXGraph() {
  return new Promise((resolve) =&amp;gt; {
    if (window.JXG) {
      resolve();
    } else {
      const checkInterval = setInterval(() =&amp;gt; {
        if (window.JXG) {
          clearInterval(checkInterval);
          resolve();
        }
      }, 100);
      
      setTimeout(() =&amp;gt; {
        clearInterval(checkInterval);
        console.error(&apos;JSXGraph loading timeout&apos;);
      }, 5000);
    }
  });
}

// 创建数学图形
async function createMathGraph(container, config) {
  const boardId = container.id;
  const JXG = window.JXG;
  
  // 获取父容器
  const wrapper = container.parentElement;
  if (!wrapper) return;
  
  // 创建布局容器
  const layoutContainer = document.createElement(&apos;div&apos;);
  layoutContainer.className = &apos;math-graph-layout&apos;;
  
  const graphContainer = document.createElement(&apos;div&apos;);
  graphContainer.className = &apos;math-graph-canvas-wrapper&apos;;
  
  const controlsContainer = document.createElement(&apos;div&apos;);
  controlsContainer.className = &apos;math-graph-controls&apos;;
  
  const controlsTitle = document.createElement(&apos;div&apos;);
  controlsTitle.className = &apos;math-graph-controls-title&apos;;
  controlsTitle.textContent = &apos;参数调整&apos;;
  controlsContainer.appendChild(controlsTitle);
  
  const slidersContainer = document.createElement(&apos;div&apos;);
  slidersContainer.className = &apos;math-graph-sliders-vertical&apos;;
  controlsContainer.appendChild(slidersContainer);
  
  // 移动容器到新布局
  wrapper.removeChild(container);
  graphContainer.appendChild(container);
  layoutContainer.appendChild(graphContainer);
  layoutContainer.appendChild(controlsContainer);
  wrapper.appendChild(layoutContainer);
  
  // 创建 JSXGraph 画板
  const board = JXG.JSXGraph.initBoard(boardId, {
    boundingbox: [-10, 10, 10, -10],
    axis: config.axis !== false,
    grid: config.grid !== false,
    showCopyright: false,
    showNavigation: false,
    keepAspectRatio: false,
  });
  
  const sliders = {};
  
  // 创建滑动条
  config.sliders.forEach((sliderConfig, index) =&amp;gt; {
    const sliderWrapper = document.createElement(&apos;div&apos;);
    sliderWrapper.className = &apos;math-slider-wrapper&apos;;
    
    const labelRow = document.createElement(&apos;div&apos;);
    labelRow.className = &apos;math-slider-label&apos;;
    
    const labelText = document.createElement(&apos;span&apos;);
    labelText.textContent = sliderConfig.name;
    
    const valueDisplay = document.createElement(&apos;span&apos;);
    valueDisplay.className = &apos;math-slider-value&apos;;
    valueDisplay.textContent = sliderConfig.value.toFixed(2);
    
    labelRow.appendChild(labelText);
    labelRow.appendChild(valueDisplay);
    
    const slider = document.createElement(&apos;input&apos;);
    slider.type = &apos;range&apos;;
    slider.min = String(sliderConfig.min);
    slider.max = String(sliderConfig.max);
    slider.step = String(sliderConfig.step);
    slider.value = String(sliderConfig.value);
    slider.className = &apos;math-slider-input&apos;;
    
    slider.addEventListener(&apos;input&apos;, () =&amp;gt; {
      const val = parseFloat(slider.value);
      valueDisplay.textContent = val.toFixed(2);
      sliders[sliderConfig.name].setValue(val);
      board.update();
    });
    
    sliderWrapper.appendChild(labelRow);
    sliderWrapper.appendChild(slider);
    slidersContainer.appendChild(sliderWrapper);
    
    // 创建 JSXGraph 内部滑动条
    sliders[sliderConfig.name] = board.create(&apos;slider&apos;, [
      [-8, -8 + index * 1.5],
      [-3, -8 + index * 1.5],
      [sliderConfig.min, sliderConfig.value, sliderConfig.max]
    ], {
      name: sliderConfig.name,
      visible: false,
    });
  });
  
  // 创建函数曲线
  config.expressions.forEach((expr, index) =&amp;gt; {
    try {
      const func = new Function(
        &apos;x&apos;, 
        ...Object.keys(sliders),
        `with(Math) { return ${expr}; }`
      );
      
      board.create(&apos;functiongraph&apos;, [
        (x) =&amp;gt; {
          const sliderValues = Object.keys(sliders).map(key =&amp;gt; sliders[key].Value());
          return func(x, ...sliderValues);
        }
      ], {
        strokeColor: getGraphColor(index),
        strokeWidth: 2,
      });
    } catch (e) {
      console.error(&apos;Expression parse error:&apos;, expr, e);
    }
  });
}

function getGraphColor(index) {
  const colors = [
    &apos;#3b82f6&apos;,
    &apos;#e74c3c&apos;,
    &apos;#27ae60&apos;,
    &apos;#f39c12&apos;,
    &apos;#9b59b6&apos;,
    &apos;#1abc9c&apos;,
  ];
  return colors[index % colors.length];
}

// 初始化
function initOnLoad() {
  waitForJSXGraph().then(() =&amp;gt; {
    observeMathGraphs();
  });
}

document.addEventListener(&apos;DOMContentLoaded&apos;, initOnLoad);

if (document.readyState !== &apos;loading&apos;) {
  initOnLoad();
}

// 支持 Swup 页面切换
document.addEventListener(&apos;swup:contentReplaced&apos;, () =&amp;gt; {
  setTimeout(initMathGraphs, 300);
});

if (window?.swup?.hooks) {
  window.swup.hooks.on(&apos;page:view&apos;, () =&amp;gt; {
    setTimeout(initMathGraphs, 300);
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;关键点说明&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;MutationObserver&lt;/strong&gt;: 监听 DOM 变化，自动初始化新添加的图形&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;waitForJSXGraph&lt;/strong&gt;: 等待 JSXGraph 库加载完成&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;防重复初始化&lt;/strong&gt;: 使用 &lt;code&gt;data-initialized&lt;/code&gt; 标记&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swup 兼容&lt;/strong&gt;: 监听页面切换事件&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;使用方法&lt;/h2&gt;
&lt;h3&gt;基本语法&lt;/h3&gt;
&lt;p&gt;在 Markdown 文件中使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;```mathgraph
sliders:
  参数名: [最小值, 默认值, 最大值, 步长]
expressions:
  - 数学表达式
axis: true/false
grid: true/false
```
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;支持的数学函数&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;基本运算&lt;/strong&gt;: &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;^&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;三角函数&lt;/strong&gt;: &lt;code&gt;sin&lt;/code&gt;, &lt;code&gt;cos&lt;/code&gt;, &lt;code&gt;tan&lt;/code&gt;, &lt;code&gt;asin&lt;/code&gt;, &lt;code&gt;acos&lt;/code&gt;, &lt;code&gt;atan&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;指数对数&lt;/strong&gt;: &lt;code&gt;exp&lt;/code&gt;, &lt;code&gt;log&lt;/code&gt;, &lt;code&gt;log10&lt;/code&gt;, &lt;code&gt;sqrt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;其他&lt;/strong&gt;: &lt;code&gt;abs&lt;/code&gt;, &lt;code&gt;floor&lt;/code&gt;, &lt;code&gt;ceil&lt;/code&gt;, &lt;code&gt;round&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;进阶定制&lt;/h2&gt;
&lt;h3&gt;自定义颜色&lt;/h3&gt;
&lt;p&gt;修改 &lt;code&gt;getGraphColor&lt;/code&gt; 函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function getGraphColor(index) {
  const colors = [
    &apos;#your-color-1&apos;,
    &apos;#your-color-2&apos;,
    // ...
  ];
  return colors[index % colors.length];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;调整画板范围&lt;/h3&gt;
&lt;p&gt;修改 &lt;code&gt;boundingbox&lt;/code&gt; 参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const board = JXG.JSXGraph.initBoard(boardId, {
  boundingbox: [-5, 5, 5, -5], // [x_min, y_max, x_max, y_min]
  // ...
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;添加更多配置选项&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;parseMathGraphConfig&lt;/code&gt; 函数中添加新的配置项：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (trimmed.startsWith(&quot;boundingbox:&quot;)) {
  const values = trimmed.split(&quot;:&quot;)[1].trim().split(&quot;,&quot;).map(Number);
  config.boundingbox = values;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;常见问题&lt;/h2&gt;
&lt;h3&gt;Q: 图形不显示？&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A&lt;/strong&gt;: 检查以下几点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;JSXGraph 文件是否正确下载到 &lt;code&gt;public/jsxgraph/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;浏览器控制台是否有错误&lt;/li&gt;
&lt;li&gt;Markdown 语法是否正确&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Q: 需要手动刷新才显示？&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A&lt;/strong&gt;: 这是初始化时机问题，确保：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;使用了 MutationObserver&lt;/li&gt;
&lt;li&gt;等待 JSXGraph 加载完成&lt;/li&gt;
&lt;li&gt;监听了 Swup 页面切换事件&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>Flags</title><link>https://blog.yuer6327.top/posts/flags/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/flags/</guid><description>一些 Flags ，也许现在就在尝试实现，也可能一直鸽着 😋</description><pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;ul&gt;
&lt;li&gt;&lt;s&gt;&lt;strong&gt;3D模型展示&lt;/strong&gt;：支持嵌入和交互式3D模型（Mathematica 支持）&lt;/s&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;智能搜索&lt;/strong&gt;：语义搜索，引用溯源&lt;/li&gt;
&lt;li&gt;&lt;s&gt;&lt;strong&gt;匿名留言板 / 说说&lt;/strong&gt;&lt;/s&gt;
参考文章：&lt;a href=&quot;https://blog.yuer6327.top/posts/fuwari%E5%8C%BF%E5%90%8D%E7%95%99%E8%A8%80%E6%9D%BF%E5%8A%9F%E8%83%BD/&quot;&gt;Fuwari匿名留言板功能&lt;/a&gt;
demo：&lt;a href=&quot;https://blog.yuer6327.top/board/&quot;&gt;说说&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;稍后再读&lt;/strong&gt;&lt;br /&gt;
可通过Localstorage存储&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>自建Fuwari静态站点及功能拓展</title><link>https://blog.yuer6327.top/posts/%E8%87%AA%E5%BB%BAfuwari%E9%9D%99%E6%80%81%E7%AB%99%E7%82%B9%E5%8F%8A%E5%8A%9F%E8%83%BD%E6%8B%93%E5%B1%95/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/%E8%87%AA%E5%BB%BAfuwari%E9%9D%99%E6%80%81%E7%AB%99%E7%82%B9%E5%8F%8A%E5%8A%9F%E8%83%BD%E6%8B%93%E5%B1%95/</guid><description>自己搭建一个简单的Fuwari静态站点，并在此基础上做一堆功能拓展...</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;花了两三天，怎么说差不多搞了个大概。也把之前wordpress上的内容大多搬运过来了。&lt;br /&gt;
其实跟着教程走很快，所以下面是本站参考并借鉴过的项目/文章：&lt;br /&gt;
（感谢各位大佬🙏🙏🙏）&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;一、站点搭建&lt;/h1&gt;
&lt;h2&gt;1.本站使用 Astro &amp;amp; Fuwari 构建：&lt;/h2&gt;
&lt;p&gt;::github{repo=&quot;saicaca/fuwari&quot;}&lt;br /&gt;
参考文章：&lt;a href=&quot;https://2x.nz/posts/fuwari/&quot;&gt;Fuwari静态博客搭建教程&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二、写文章&lt;/h1&gt;
&lt;h2&gt;2.MD编辑器+配置&lt;/h2&gt;
&lt;p&gt;推荐 &lt;code&gt;Obsidian（黑曜石）&lt;/code&gt;&lt;br /&gt;
::github{repo=&quot;obsidianmd/obsidian-releases&quot;}&lt;br /&gt;
方便起见，直接把Obsidian仓库目录设在跟你的fuwari项目&lt;code&gt;同目录&lt;/code&gt;下，&lt;br /&gt;
然后配置图片链接，md文件中不能直接内嵌图片，需要手动设置每个图片的链接。&lt;br /&gt;
为了已经习惯了 Ctrl+CV 的我们写文章更加丝滑，需要在Obsidian中更改默认附件存储地址：&lt;br /&gt;
&lt;code&gt;设置 -&amp;gt; 文件与链接&lt;/code&gt; 修改两条：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;附件默认存放路径&lt;/th&gt;
&lt;th&gt;-&amp;gt;&lt;/th&gt;
&lt;th&gt;指定的附件文件夹&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;附件文件夹路径&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-&amp;gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;public/assets/images&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;关闭&lt;code&gt;使用Wiki链接&lt;/code&gt;：&lt;br /&gt;
&lt;img src=&quot;/public/assets/images/image-1.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;于是你在写文章的时候就可以像word一样丝滑啦~&lt;br /&gt;
附示例文件架构如图：
&lt;img src=&quot;/public/assets/images/image.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/public/assets/images/image-2.png&quot; alt=&quot;&quot; /&gt;
（Obisidian &lt;code&gt;v1.12.0&lt;/code&gt; 起原生支持自动删除附件）&lt;/p&gt;
&lt;p&gt;于是你又可能发现&lt;a href=&quot;https://blog.yuer6327.top/rss.xml&quot;&gt; RSS 订阅&lt;/a&gt;中的图片无法正常显示，于是聪明的你打开了这篇文章：&lt;a href=&quot;https://blog.isyyo.com/posts/fuwari_rss_pictures/&quot;&gt;Fuwari RSS 图片路径修复指北 - Wer Blog&lt;/a&gt;&lt;br /&gt;
（懒得看的话可以像我一样丢给AI）&lt;/p&gt;
&lt;p&gt;然后应该就能在你的 RSS 阅读器里看到所有图片了：
&lt;img src=&quot;/public/assets/images/68b8d7f055e1288d8d2b073879be136f.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;三、功能拓展&lt;/h1&gt;
&lt;h2&gt;3.评论系统：Twikoo&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://blog.qqquq.com/posts/fuwari-twikoo-comments/&quot;&gt;给你的 Fuwari 接入 Twikoo 评论&lt;/a&gt;&lt;br /&gt;
demo：翻到页面最下面&lt;/p&gt;
&lt;h2&gt;4.运行时间&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://aulypc1.github.io/posts/website/add_icpruntime_in_fuwari-footer/&quot;&gt;如何在Fuwari页脚添加ICP、运行时间等信息&lt;/a&gt;&lt;br /&gt;
demo：翻到页面最最下面&lt;/p&gt;
&lt;h2&gt;5.Mermaid 图表&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://mc.mimeng.top/posts/frontend/fuwari-mermaid-integration-guide/&quot;&gt;为你的 Fuwari 集成 Mermaid 图表支持&lt;/a&gt;&lt;br /&gt;
demo：&lt;a href=&quot;../demo/#mermaid-%E5%9B%BE%E8%A1%A8&quot;&gt;Demo&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;6.密码保护&lt;/h2&gt;
&lt;p&gt;参考文章 &amp;amp; demo：&lt;a href=&quot;https://blog.170529.xyz/posts/password-test/&quot;&gt;给Fuwari博客添加密码保护功能（test123）&lt;/a&gt;
&lt;a href=&quot;https://www.lapis.cafe/posts/technicaltutorials/astro-fuwari-page-lock/&quot;&gt;基于 Astro&amp;amp;Fuwari 主题博客的页面加密功能研究 | 时歌的博客&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;7.友链&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://blog.skarie.top/posts/fuwari/fuwari%E6%B7%BB%E5%8A%A0%E5%8F%8B%E9%93%BE%E9%A1%B5%E9%9D%A2/fuwari%E6%B7%BB%E5%8A%A0%E5%8F%8B%E9%93%BE%E9%A1%B5%E9%9D%A2/&quot;&gt;Fuwari 博客添加友链页面详细教程 - 翊羽不会飞&lt;/a&gt;&lt;br /&gt;
demo：&lt;a href=&quot;https://blog.yuer6327.top/links/&quot;&gt;友链 - Yuer6327&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;8.文章反应&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://blog.yuer6327.top/posts/fuwari%E6%96%87%E7%AB%A0%E5%8F%8D%E5%BA%94%E5%8A%9F%E8%83%BD/&quot;&gt;Fuwari文章反应功能&lt;/a&gt;&lt;br /&gt;
demo：本站就有&lt;/p&gt;
&lt;h2&gt;9.文章分享&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://blog.yuer6327.top/posts/fuwari%E6%96%87%E7%AB%A0%E5%88%86%E4%BA%AB%E5%8A%9F%E8%83%BD/&quot;&gt;Fuwari文章分享功能&lt;/a&gt;&lt;br /&gt;
demo：本站就有&lt;/p&gt;
&lt;h2&gt;10.侧边栏天气组件&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://blog.yuer6327.top/posts/fuwari%E4%BE%A7%E8%BE%B9%E6%A0%8F%E5%A4%A9%E6%B0%94%E7%BB%84%E4%BB%B6/&quot;&gt;Fuwari侧边栏天气组件&lt;/a&gt;&lt;br /&gt;
demo：本站就有&lt;/p&gt;
&lt;h2&gt;11.匿名留言板（说说）&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://blog.yuer6327.top/posts/fuwari%E5%8C%BF%E5%90%8D%E7%95%99%E8%A8%80%E6%9D%BF%E5%8A%9F%E8%83%BD/&quot;&gt;Fuwari匿名留言板功能&lt;/a&gt;&lt;br /&gt;
demo：&lt;a href=&quot;https://blog.yuer6327.top/board/&quot;&gt;说说&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;12.稍后再读&lt;/h2&gt;
&lt;p&gt;参考文章：&lt;a href=&quot;https://blog.yuer6327.top/posts/fuwari%E7%A8%8D%E5%90%8E%E5%86%8D%E8%AF%BB%E5%8A%9F%E8%83%BD%E5%AE%9E%E7%8E%B0/&quot;&gt;Fuwari「稍后再读」功能实现&lt;/a&gt;&lt;br /&gt;
demo：本站侧栏&lt;/p&gt;
</content:encoded></item><item><title>first-post</title><link>https://blog.yuer6327.top/posts/first-post/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/first-post/</guid><description>first post here</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;好吧这段时间老有闲人来打动态站（我觉得我的站点毫无攻击价值啊？）&lt;/p&gt;
&lt;p&gt;infinity 限制每日hits上限然后就直接强制下线了&lt;/p&gt;
&lt;p&gt;emm好吧那我觉得是时候转静态站点了：
所以这是一个托管在cloudflare大善人上的静态站点......也许以后会彻底转向静态博客，但是目前还有很长一段路要走（还有很多功能要做），我尽量在寒假结束之前把这个静态站基础搞好吧。&lt;/p&gt;
&lt;p&gt;2/28 更新：&lt;br /&gt;
开学前最后一天，随着中东燃起的战火，寒假也是接近尾声了 qaq&lt;br /&gt;
那只能先搞到这了，具体建站日志转：&lt;a href=&quot;../%E8%87%AA%E5%BB%BAfuwari%E9%9D%99%E6%80%81%E7%AB%99%E7%82%B9/&quot;&gt;自建Fuwari静态站点 - Yuer6327&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>小项目：单PHP在线聊天室 🌱</title><link>https://blog.yuer6327.top/posts/%E5%B0%8F%E9%A1%B9%E7%9B%AE%E5%8D%95php%E5%9C%A8%E7%BA%BF%E8%81%8A%E5%A4%A9%E5%AE%A4-/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/%E5%B0%8F%E9%A1%B9%E7%9B%AE%E5%8D%95php%E5%9C%A8%E7%BA%BF%E8%81%8A%E5%A4%A9%E5%AE%A4-/</guid><description>Backup from old</description><pubDate>Fri, 02 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;:::note
项目已搁置，后续开发将由&lt;a href=&quot;https://www.mkliu.top/&quot;&gt;mkliu&lt;/a&gt;继续
:::&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;&amp;lt;michaelliunsky&amp;gt;/&amp;lt;Chatom&amp;gt;&quot;}&lt;/p&gt;
&lt;p&gt;2026新年快乐！&lt;br /&gt;
翻代码时翻到这个小项目，去年和&lt;a href=&quot;https://github.com/michaelliunsky&quot;&gt;michaelliunsky&lt;/a&gt;随手写的轻量级聊天室系统（为了上信息课的时候不依赖其他聊天平台......）现在高一信息合格考结束后没有信息课了这个项目就荒废了😅 现在想起来又维护了一下，嘿嘿还能用！😋 那就干脆重构一下当个大更新罢✨&lt;br /&gt;
&lt;strong&gt;（&amp;lt;mark&amp;gt;AI&amp;lt;/mark&amp;gt;长文预警🚨🚨🚨）&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;为什么要做这个？&lt;/h2&gt;
&lt;p&gt;事情是这样的：&lt;s&gt;有次团队临时要讨论需求&lt;/s&gt;信息课有远程聊天需求，但发现常用工具要么要注册登录、要么要下载安装客户端，甚至还有要付费的...我就想：&quot;能不能用最简单的方式搭个临时聊天室？&quot; 于是花了一个周末，撸出了这个&lt;strong&gt;单文件PHP解决方案&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;它到底有多&quot;轻&quot;？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心就一个文件&lt;/strong&gt;：整个系统只有 &lt;a href=&quot;https://github.com/Yuer6327/chatroom-online/blob/main/index.php&quot;&gt;&lt;code&gt;index.php&lt;/code&gt;&lt;/a&gt;（加上README总共2个文件）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;零依赖&lt;/strong&gt;：不需要数据库，不需要框架，PHP 7.0+ 直接跑&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;部署超简单&lt;/strong&gt;：上传&lt;a href=&quot;https://github.com/Yuer6327/chatroom-online/blob/main/index.php&quot;&gt;&lt;code&gt;index.php&lt;/code&gt;&lt;/a&gt; -&amp;gt; &lt;code&gt;改两行配置（标题/logo）&lt;/code&gt;-&amp;gt; 搞定！&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;作为开发者，我最得意的设计&lt;/h2&gt;
&lt;h3&gt;1. 文件存储的巧思&lt;/h3&gt;
&lt;p&gt;不用数据库不是偷懒，而是刻意为之。聊天记录直接存在 &lt;code&gt;data/&lt;/code&gt; 目录下：&lt;/p&gt;
&lt;p&gt;php&lt;code&gt;*// 每个房间一个 .txt 文件* $room_file = __DIR__ . &apos;/data/&apos; . $room . &apos;.txt&apos;;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这样做的好处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;避免数据库单点故障&lt;/li&gt;
&lt;li&gt;直接用文本编辑器就能看历史消息（排查问题超方便）&lt;/li&gt;
&lt;li&gt;自动清理旧消息的逻辑特别简单：php&lt;code&gt;$del_time = date(&apos;Y-m-d H:i:s&apos;, time() - 2592000); *// 30天*&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 加密传输的小把戏&lt;/h3&gt;
&lt;p&gt;消息不是明文传输的，用了个简单的双重加密：&lt;/p&gt;
&lt;p&gt;php&lt;code&gt;function encodeContent($content) {     $content = base64_encode(urlencode($content));     *// 再做一层字符替换...* }&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;虽然比不上TLS，但至少能防住隔壁工位偷看屏幕 😜&lt;/p&gt;
&lt;h3&gt;3. 长轮询的Nginx适配&lt;/h3&gt;
&lt;p&gt;最头疼的是Nginx环境下&lt;code&gt;sleep()&lt;/code&gt;会卡住整个服务，最后用这个方案解决：&lt;/p&gt;
&lt;p&gt;php&lt;code&gt;if (strpos($_SERVER[&apos;SERVER_SOFTWARE&apos;], &apos;nginx&apos;) !== false) {     *// 直接获取消息* } else {     *// 普通环境用sleep轮询* }&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;它不完美，但很实用&lt;/h2&gt;
&lt;p&gt;当然啦，作为个人项目，它也有局限：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;❌ 没有用户系统（靠浏览器localStorage记昵称）&lt;/li&gt;
&lt;li&gt;❌ 传输会卡（毕竟是文本存储）&lt;/li&gt;
&lt;li&gt;❌ 移动端体验一般（CSS没专门优化）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但作为&lt;strong&gt;临时沟通场景&lt;/strong&gt;，它真的够用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;开会时快速建个房间分享链接 ✅&lt;/li&gt;
&lt;li&gt;信息课临时偷偷建个群聊 ✅&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;最后说点心里话&lt;/h2&gt;
&lt;p&gt;如果你也需要个&lt;strong&gt;轻量、可控、不折腾&lt;/strong&gt;的聊天工具，欢迎试试看： 👉 &lt;a href=&quot;https://github.com/Yuer6327/chatroom-online&quot;&gt;GitHub地址&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;**PS：**用得好记得点个star，有问题随时提issue，咱们一起让这个小项目更好用~ 🙌&lt;/p&gt;
&lt;p&gt;（悄悄说：最近在考虑加个&quot;消息撤回&quot;功能，有啥建议欢迎留言呀！）&lt;/p&gt;
</content:encoded></item><item><title>10-20</title><link>https://blog.yuer6327.top/posts/10-20/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/10-20/</guid><description>Backup from old &amp; Corrected a few typos</description><pubDate>Mon, 20 Oct 2025 00:00:00 GMT</pubDate><content:encoded/></item><item><title>RSS</title><link>https://blog.yuer6327.top/posts/rss/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/rss/</guid><description>Backup from old</description><pubDate>Tue, 12 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Wikipedia：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RSS（英文全称：RDF Site Summary 或 Really Simple Syndication），中文译作简易信息聚合，也称聚合内容，是一种消息来源格式规范，用以聚合多个网站更新的内容并自动通知网站订阅者。使用RSS后，网站订阅者无需手动查看网站是否有新的内容，同时RSS能将多个网站的更新集成并以摘要形式呈现，有助于订阅者快速获取重要信息，并选择性点击查看。&lt;/p&gt;
&lt;p&gt;RSS摘要可以借由RSS阅读器、Feed Reader或aggregator等网页或以桌面为架构的软件来阅读。标准的XML档式可允许信息在一次发布后通过不同的程序阅览。用户借由将网摘输入RSS阅读器，或是用鼠标点取浏览器上指向订阅程序的RSS小图标URI（非通常所称的URL）来订阅网摘。RSS阅读器会定期检查网站更新情况，并将更新内容下载到用户界面。&lt;/p&gt;
&lt;p&gt;本站RSS：&lt;a href=&quot;https://blog.yuer6327.top/rss.xml&quot;&gt;https://blog.yuer6327.top/rss.xml&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;推荐RSS阅读器：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/WCY-dt/MrRSS&quot;&gt;MrRss&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://folo.is&quot;&gt;Folo&lt;/a&gt;&lt;br /&gt;
This message is used to verify that this feed (feedId:249800300973418496) belongs to me (userId:176917412196345856). Join me in enjoying the next generation information browser https://folo.is.&lt;/p&gt;
&lt;p&gt;订阅链接示例：&lt;/p&gt;
&lt;p&gt;链接：&lt;a href=&quot;https://blog.yuer6327.top/rss.xml&quot;&gt;https://blog.yuer6327.top/rss.xml&lt;/a&gt;、&lt;a href=&quot;https://blog.mkliu.top/feed&quot;&gt;https://blog.mkliu.top/feed&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;RSShub：rsshub://twitter/user/sama、rsshub://bilibili/user/video/163637592（详见&lt;a href=&quot;https://docs.rsshub.app/&quot;&gt;rsshub官方文档&lt;/a&gt;）&lt;/p&gt;
</content:encoded></item><item><title>研究日志-平台篇</title><link>https://blog.yuer6327.top/posts/%E7%A0%94%E7%A9%B6%E6%97%A5%E5%BF%97-%E5%B9%B3%E5%8F%B0%E7%AF%87/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/%E7%A0%94%E7%A9%B6%E6%97%A5%E5%BF%97-%E5%B9%B3%E5%8F%B0%E7%AF%87/</guid><description>Backup from old</description><pubDate>Mon, 14 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;时间&lt;/th&gt;
&lt;th&gt;操作&lt;/th&gt;
&lt;th&gt;内容&lt;/th&gt;
&lt;th&gt;字数&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2025-05-27 22:47:52&lt;/td&gt;
&lt;td&gt;新增&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/4cd73117dde97456908386c1230032717a505cd9&quot;&gt;Initial_Update-20250527&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/3d173b153d31732dc3edc85624d050d566916cf9&quot;&gt;Create .gitignore&lt;/a&gt;&amp;lt;br&amp;gt;创建主要目录（如 assets、includes、insurer、user 等）&amp;lt;br&amp;gt;完成最基础的代码文件&amp;lt;br&amp;gt;实现基本平台功能&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-02 15:31:15&lt;/td&gt;
&lt;td&gt;新增&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/32f74c2104d483bfebd9b4514772e13404c10933&quot;&gt;add length restriction&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/84260cdd233d644fe1ea888fe946ebcc999343a1&quot;&gt;also in login page&lt;/a&gt;&amp;lt;br&amp;gt;增加对输入框输入长度的限制（主要是车牌号输入部分）&amp;lt;br&amp;gt;根据《中华人民共和国机动车号牌》国家标准：&amp;lt;br&amp;gt;一、车牌号格式规定（燃油车牌）&amp;lt;br&amp;gt;燃油车牌号由&lt;strong&gt;七位字符&lt;/strong&gt;组成，结构如下：&amp;lt;br&amp;gt;第1位：&lt;strong&gt;省级行政区简称&lt;/strong&gt;（如“粤”、“沪”、“京”等）；&amp;lt;br&amp;gt;第2位：&lt;strong&gt;发牌机关代号&lt;/strong&gt;（英文字母，如“A”、“B”等）；&amp;lt;br&amp;gt;第3至7位：&lt;strong&gt;序号&lt;/strong&gt;，由&lt;strong&gt;阿拉伯数字&lt;/strong&gt;和&lt;strong&gt;英文字母&lt;/strong&gt;组合而成，最多可使用&lt;strong&gt;两个英文字母&lt;/strong&gt;。&amp;lt;br&amp;gt;二、燃油车牌中&lt;strong&gt;禁止使用的字母&lt;/strong&gt;&amp;lt;br&amp;gt;在燃油车牌的序号部分（即后五位），&lt;strong&gt;禁止使用以下两个字母&lt;/strong&gt;：&amp;lt;br&amp;gt;&lt;strong&gt;字母“O”&lt;/strong&gt;（易与数字“0”混淆）&amp;lt;br&amp;gt;&lt;strong&gt;字母“I”&lt;/strong&gt;（易与数字“1”混淆）&amp;lt;br&amp;gt;注：该限制适用于所有燃油机动车，包括小型、大型汽车、摩托车等，&lt;strong&gt;不区分车辆类型&lt;/strong&gt;。&lt;/td&gt;
&lt;td&gt;445&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-02 22:02:42&lt;/td&gt;
&lt;td&gt;新增&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/32f74c2104d483bfebd9b4514772e13404c10933&quot;&gt;&lt;/a&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/f9d97018b84d62184067e8c4f3c0c164f44b3044&quot;&gt;add function: check availability&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/043145e4fbfacdb88414a04622ddd1cde8654c02&quot;&gt;add front end support&lt;/a&gt;&amp;lt;br&amp;gt;增加对输入框输入车牌号合理性的检查和前端反馈（格式不合规显示红色边框）&amp;lt;br&amp;gt;&lt;/td&gt;
&lt;td&gt;446&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-09 10:48:25&lt;/td&gt;
&lt;td&gt;新增&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/5d7f349f46d2408a38aa69968be8d9ce0c58e29d&quot;&gt;add NEV-support&lt;/a&gt;&amp;lt;br&amp;gt;增加对新能源车牌的识别（显示绿点）&amp;lt;br&amp;gt;依据中华人民共和国公共安全行业标准《&lt;a href=&quot;https://zh.wikipedia.org/wiki/File:%E4%B8%AD%E5%8D%8E%E4%BA%BA%E6%B0%91%E5%85%B1%E5%92%8C%E5%9B%BD%E6%9C%BA%E5%8A%A8%E8%BD%A6%E5%8F%B7%E7%89%8C%E6%A0%87%E5%87%86%E3%80%94%E4%B8%AD%E5%8D%8E%E4%BA%BA%E6%B0%91%E5%85%B1%E5%92%8C%E5%9B%BD%E5%85%AC%E5%85%B1%E5%AE%89%E5%85%A8%E8%A1%8C%E4%B8%9A%E6%A0%87%E5%87%86GA36%E2%80%942007%E3%80%95.PDF&quot;&gt;中华人民共和国机动车号牌&lt;/a&gt;》（GA36—2018），小型新能源汽车号牌号码为6位数。号牌号码首位字母“D”（优先启用）、“A”、“B”、“C”、“E”代表&lt;a href=&quot;https://zh.wikipedia.org/wiki/%E7%BA%AF%E7%94%B5%E5%8A%A8%E6%B1%BD%E8%BD%A6&quot;&gt;纯电动汽车&lt;/a&gt;。号牌号码首位字母“F”（优先启用）、“G”、“H”、“J”、“K”代表非纯电动汽车。&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-09 18:48:20&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/549443cf98f4a9cf1c4d14b4a612ef6e5dd8a0d8&quot;&gt;improve UI design&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/78b227a7be6f9dd796fb82ce33dec9168f1f33d2&quot;&gt;center location&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/607731cf1f435f3e1bae1ee0e17b213213c362f6&quot;&gt;Dark Mode Support&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/d5c20f5a91e3aa4543da0280ee4a2242227fe058&quot;&gt;fix location&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/7420b7904dc94e0a4760ff91426e3e89070666ec&quot;&gt;add icon&lt;/a&gt;&amp;lt;br&amp;gt;更新前端交互界面设计&amp;lt;br&amp;gt;优化排版、增加黑色主题、设计图标&lt;/td&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-25 15:15:42&lt;/td&gt;
&lt;td&gt;新增&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/ada0bc6eaadae048df42a2081864066dbd6c11d1&quot;&gt;add function: charts&lt;/a&gt;&amp;lt;br&amp;gt;增加图表获得更好的数据呈现&lt;/td&gt;
&lt;td&gt;203&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-25 19:59:16&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/9d3cefa4a3273b0c01d9a60539c38992e7c22ede&quot;&gt;add Gradient&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/9e2303975b76ec077d401776696453c5dc7ca9b5&quot;&gt;add card scheme&lt;/a&gt;&amp;lt;br&amp;gt;更新前端交互界面设计&amp;lt;br&amp;gt;增加标题渐变色、用卡片式布局呈现不同内容&lt;/td&gt;
&lt;td&gt;297&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-25 21:12:39&lt;/td&gt;
&lt;td&gt;新增&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/b220d22e7f5c7213cbbdde60815c4c80ef776faa&quot;&gt;add trends&lt;/a&gt;&amp;lt;br&amp;gt;增加未来趋势预测&lt;/td&gt;
&lt;td&gt;205&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-25 23:21:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/9c6733f1f161ee65d73001a84f7ccab6ad800dc1&quot;&gt;delete dark mode&lt;/a&gt;&amp;lt;br&amp;gt;删除黑色主题支持&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-26 08:35:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/3e05a4b5771de90d02afbff8baaff9a93e19c7bc&quot;&gt;delete useless css&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/081f9af3bb9787a34eebd807a838abf25234a480&quot;&gt;rebuild combobox&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/aa168f07a8425257ae9467c5f010a8fdd5933ce3&quot;&gt;fix combobox logic&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/652b1f40135eafbe0ddb2f70f4a20d04f4c7a930&quot;&gt;fix combobox abnormal behaviour&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/91fc69c8a8ae7f7956616ffeeee914cd11b0ad6e&quot;&gt;rewrite combobox focus&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/03f33ce2db8cc366dd0a5585848a42960daf431a&quot;&gt;DEL BLUEEEEE&lt;/a&gt;&amp;lt;br&amp;gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/0337a2648986cc9bdc87057641f6689d7534abb0&quot;&gt;fix combobox background&lt;/a&gt;&amp;lt;br&amp;gt;删除无用代码，修改下拉框样式以符合整体设计&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-27 15:07:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/059b64a14074b451e258a6d7e60d7e13a44aa7df&quot;&gt;Refactor custom select hover and highlight effects&lt;/a&gt;&amp;lt;br&amp;gt;Simplified and unified the hover effect for custom select options by moving styling to CSS and removing complex JS highlight logic from script.js, script_new.js, and ios26_select.js. Fixed a typo in ios26_select.css, adjusted margin and padding for select components, and removed debug code and redundant theme initialization from login.php.&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-27 15:45:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/c9aa3836cf89baa5c6ff0963cd0a631630fc35c3&quot;&gt;Refactor vehicle field logic and plate validation&lt;/a&gt;&amp;lt;br&amp;gt;Moved vehicle field show/hide logic from script.js to register.php for better integration with the custom select. Simplified and improved the car plate number validation logic in both PHP and JavaScript. Cleaned up redundant comments and styles for maintainability.&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-27 16:15:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/db048f92b7edc6934e63691f9e5f626ba02558a1&quot;&gt;rewrite input box prompts&lt;/a&gt;&amp;lt;br&amp;gt;重写输入框动画逻辑，实现更加简洁明了的输入框设计&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-27 17:07:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/0aa8af3a9cc1a00ca6131625c02c49fee50edac4&quot;&gt;Improve CSS on insurer side.&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-27 17:14:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/320b81dd9ff1af622b2b0c043f11f4407315b262&quot;&gt;adjust spacing between input boxs&lt;/a&gt;&amp;lt;br&amp;gt;调整下拉框与输入框间距，获得统一的视觉&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-27 17:14:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/220bb809181aa6a125ab9eb5f319bdbcda18cdb3&quot;&gt;rearrange insurer_dashboard&lt;/a&gt;&amp;lt;br&amp;gt;重构保险方管理面板&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-27 19:20:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/91fc00029801b37d12e30244d9cd5033eeb28579&quot;&gt;Refactor dashboard and vehicle list layout styles&lt;/a&gt;&amp;lt;br&amp;gt;Updated the dashboard max-width and removed redundant/duplicate CSS in style.css. Simplified the vehicle list layout in insurer_dashboard.php by removing JavaScript-based dynamic class switching and related CSS, using a responsive grid layout instead.&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-07-24 19:20:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/30c36bf7f4e9d7d7887d6268ea9bbdfd1e9231ae&quot;&gt;fix all icon&lt;/a&gt;&amp;lt;br&amp;gt;修复所有页面上图标缺失问题&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-07-25 15:13:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/9367eded2d1b48a99fad385a8060f5215e6855f7&quot;&gt;fix Chinese character imput problem&lt;/a&gt;&amp;lt;br&amp;gt;修复输入框中文字符输入错误问题&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-07-27 16:20:39&lt;/td&gt;
&lt;td&gt;修订&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/michaelliunsky/Car_Insurance/commit/61b0fd6200b76c0642457347017902f7e34dc6dc&quot;&gt;Add CSS&lt;/a&gt;&amp;lt;br&amp;gt;Refactor code structure for improved readability and maintainability&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>科创2025</title><link>https://blog.yuer6327.top/posts/%E7%A7%91%E5%88%9B2025/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/%E7%A7%91%E5%88%9B2025/</guid><description>Backup from old</description><pubDate>Mon, 19 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;目标：确定具体检测什么东西+找司机行为的数据库&lt;/p&gt;
&lt;p&gt;车体（轮胎、剐蹭、碰撞、车灯）&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;太平洋：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;包括汽车外观，要目视检查左右后视镜、内后视镜，保证完好无损。&lt;/p&gt;
&lt;p&gt;制动系统得检查各故障指示灯的指示状况，确保无故障报警。&lt;/p&gt;
&lt;p&gt;转向系统不能有松旷和开裂现象。&lt;/p&gt;
&lt;p&gt;照明、信号指示灯要正常工作。轮胎状况得良好。&lt;/p&gt;
&lt;p&gt;轮胎压力、磨损评估，刹车系统中刹车片、刹车盘磨损程度和刹车液位检查，车灯及信号灯包括前大灯、转向灯、刹车灯等是否正常，发动机及传动系统如皮带、风扇等的检查，悬挂和排气系统的检查，油箱、油管、油封等的查看，车辆外观内饰有无破损、漏水，还有发动机检测，查看其工作状态，如皮带、风扇等部件。变速器检测其性能和有无异常。转向系统检测包括转向轮侧滑和部件磨损程度。底盘检测结构完整性和部件稳固性。轮胎检测压力、磨损和花纹深度。汽车电子系统检测像车灯及信号灯。汽车玻璃检测有无破损或裂痕。车内环境检测空气质量。安全设施检测像车门应急开关、安全顶窗、安全锤、灭火器等能否正常使用。&lt;/p&gt;
&lt;p&gt;一、汽车外观检查&lt;/p&gt;
&lt;p&gt;1.目视检查车身外观和内部环境。车体应清洁完好，无开裂、明显腐蚀和变形。车窗玻璃应齐全完好，车内整洁。&lt;/p&gt;
&lt;p&gt;2目视检查左右后视镜和车内后视镜，确保完好无损。&lt;/p&gt;
&lt;p&gt;3打开挡风玻璃刮水器开关，刮水器各档位应工作正常。当雨刮器关闭时，雨刮片应自动回到初始位置。&lt;/p&gt;
&lt;p&gt;4目视检查发动机和水箱，不应有漏油或漏液现象。&lt;/p&gt;
&lt;p&gt;二、汽车制动系统的检查&lt;/p&gt;
&lt;p&gt;1气压计的工作状态&lt;/p&gt;
&lt;p&gt;启动发动机并观察气压计的指示，气压计应能正确显示系统压力。&lt;/p&gt;
&lt;p&gt;2制动管路的密封性&lt;/p&gt;
&lt;p&gt;对于采用气压制动的车辆，在气缸保持一定压力的情况下，关闭发动机，踩下制动踏板，检查各车轮制动气室、气阀和制动管的密封性，不应有漏气现象。&lt;/p&gt;
&lt;p&gt;3制动系统自检&lt;/p&gt;
&lt;p&gt;打开发动机起动开关，检查制动系统各故障指示灯的指示状态，无故障报警。&lt;/p&gt;
&lt;p&gt;4空气压缩机驱动皮带&lt;/p&gt;
&lt;p&gt;目视检查并用手指按压空气压缩机的传动带。不应有裂纹、油渍和异常磨损，松紧适宜。&lt;/p&gt;
&lt;p&gt;三、汽车转向系统的检查&lt;/p&gt;
&lt;p&gt;左右转动方向盘，检查球销总成和横拉杆。球销总成应无松动和裂纹，横拉杆应无变形和裂纹，所有锁销应完全紧固。同时检查转向机构的连接状况，连接部位应可靠无松动。四个。应目视检查传动系统1的传动机构和连接，看传动轴支架是否损坏和变形。摇动传动轴，检查传动机构的连接状态。万向节和中间轴承应无松动。2自动变速器和液力减速器的密封&lt;/p&gt;
&lt;p&gt;对于同时装有自动变速器和液力缓速器的车辆，目视检查自动变速器和液力缓速器的密封情况，不应有漏油现象。&lt;/p&gt;
&lt;p&gt;五、汽车照明、信号指示灯检查&lt;/p&gt;
&lt;p&gt;1个前照灯&lt;/p&gt;
&lt;p&gt;对前照灯进行外观检查，前照灯应完整无损，表面清晰无松动；打开大灯，应该工作正常；操作远近光开关，远近光开关应正常。&lt;/p&gt;
&lt;p&gt;2信号指示灯&lt;/p&gt;
&lt;p&gt;检查巡逻转向信号(前、后和侧)、刹车灯、示廓灯、危险警告灯和前后雾灯。它们应完整且状况良好，表面清洁；分别进行相应的操作，并目视检查上述信号指示器，所有指示器均应正常工作。&lt;/p&gt;
&lt;p&gt;用不及物动词检测汽车轮胎&lt;/p&gt;
&lt;p&gt;1轮胎外观&lt;/p&gt;
&lt;p&gt;目视检查胎冠、胎壁等。不得有裂纹、切口、凸起、异物渗透等缺陷。长度超过25毫米或深度足以暴露。窗帘布。同时，目视检查并安装轮胎室，不得有异物嵌入。&lt;/p&gt;
&lt;p&gt;2轮胎胎面深度&lt;/p&gt;
&lt;p&gt;目视检查轮胎磨损情况。如有必要，使用胎面深度计测试轮胎胎冠的胎面深度。营运客车方向盘胎面花纹深度不小于3.2毫米，其他轮胎胎面花纹深度不小于1.6毫米.&lt;/p&gt;
&lt;p&gt;3轮胎尺寸和模式&lt;/p&gt;
&lt;p&gt;目测轮胎尺寸和花纹，同一轴线两侧的轮胎尺寸和花纹要一致。&lt;/p&gt;
&lt;p&gt;4轮胎压力&lt;/p&gt;
&lt;p&gt;检查每个轮胎的充气状态，必要时用气压计测量轮胎气压。胎压符合要求。&lt;/p&gt;
&lt;p&gt;5.轮胎和车轴螺栓和螺母&lt;/p&gt;
&lt;p&gt;检查轮胎的螺栓和螺母以及可见的车轴螺栓。所有轮胎和半轴的螺栓螺母应齐全、完好、紧固。&lt;/p&gt;
&lt;p&gt;七、悬挂系统检查&lt;/p&gt;
&lt;p&gt;目视检查悬架的弹性元件&lt;/p&gt;
&lt;p&gt;目视检查灭火器，灭火器应随车配备。&lt;/p&gt;
&lt;p&gt;九、摄像行车记录仪&lt;/p&gt;
&lt;p&gt;目测车内摄像头，摄像头拍摄方向应符合规定，无遮挡。&lt;/p&gt;
&lt;p&gt;综合性能检测除了安全性能检测的内容，还检测工作可靠性、动力性、燃油消耗、环保等。具体利用专用汽车检测设备对汽车进行规定项目检测，可分为转向轮侧滑、制动性能、车速表误差、前照灯性能、废气排放、喇叭声级和噪声这六项。&lt;/p&gt;
&lt;p&gt;汽车安全检测项目：具体包括外观、制动系统、转向系统、传动系统、照明（信号指示灯）、轮胎&lt;/p&gt;
&lt;p&gt;车辆外观检查 ：1、漆面无碰刮；2、灯罩完整，灯光正常；3、保险杠正常；4、车辆表面清洁。&lt;/p&gt;
&lt;p&gt;车辆状况检查：1、空调可正常使用；2、坐垫干净整洁；3、喇叭、刮水器、中控锁工作正常；4、仪表盘与操作台正常；5、转向、离合器、变速器、油门踏板灵活可靠，制动器（含手制动）有效； 6、前后轮胎及备胎气压正常；轮胎无严重磨损；7、后备箱无杂物。&lt;/p&gt;
&lt;p&gt;安全检查：1、门锁完好无损；2、防盗器正常。&lt;/p&gt;
&lt;p&gt;运行检查：1、发动机运转正常，无异响与异味；2、尾气排放无冒黑烟和蓝烟现象。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://max.book118.com/html/2025/0329/5043014204012123.shtm&quot;&gt;基于多源数据融合的车体健康数据检测系统设计与实现.docx-原创力文档&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;http://www.knowcat.cn/p/20241223/2276328.html&quot;&gt;基于新型压电传感的汽车结构健康检测技术 - 商业论文&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;数据库：&lt;/p&gt;
&lt;p&gt;https://magichub.com/datasets/driver-behaviors-dataset-for-dms&lt;/p&gt;
&lt;p&gt;https://github.com/chaopengzhang/100-DrivingStyle-Dataset&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.dolphindb.cn/zh/tutorials/behavioral_profiling_of_driving_habits.html&quot;&gt;使用 DolphinDB 进行汽车用户驾驶习惯行为画像分析&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/561387661&quot;&gt;最全自动驾驶数据集分享系列七 | 驾驶行为数据集 - 知乎&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.csdn.net/weixin_46440934/article/details/127120679&quot;&gt;【数据集】驾驶员分心检测数据集（State Farm Distracted Driver Detection）_state farm数据集-CSDN博客&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.huaweicloud.com/solution/implementations/driving-behavior-analysis.html&quot;&gt;驾驶行为分析-华为云&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://cloud.tencent.com/developer/article/1371657&quot;&gt;本田公布104小时驾驶行为数据集：时间不长但胜在全面 | 附相关资源汇总-腾讯云开发者社区-腾讯云&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>Hello!</title><link>https://blog.yuer6327.top/posts/hello/</link><guid isPermaLink="true">https://blog.yuer6327.top/posts/hello/</guid><description>Backup from old</description><pubDate>Tue, 21 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;/assets/images/waving-hand-fluent-3-4.png&quot; alt=&quot;Hello!&quot; /&gt;&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/public/assets/images/waving-hand-fluent-256.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Welcome here! This is Yuer6327&apos;s personal blog here.&lt;/p&gt;
&lt;p&gt;Still under construction&lt;/p&gt;
</content:encoded></item></channel></rss>