Files
blog/posts/f287c563/index.html
T
biss f839e19947 更新包
-- [CST 2026-08-04 Tuesday 16:05:05]
2026-08-04 16:05:05 +08:00

1524 lines
155 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html><html lang="zh-CN"> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="description" content="这篇文章介绍了如何在博客中集成Typesense搜索引擎,包括安装Typesense、添加数据集以及同步数据的详细步骤。首先,通过命令行安装Typesense并配置相关参数,如API密钥和数据目录。接着,安装必要的npm包,并在Hexo博客中配置搜索功能,指定存储搜索数据的JSON文件路径。然后,编写了一个同步脚本,用于读取XML文件、解析数据、检查或创建集合以及导入数据到Typesense。最后,在博客底部添加了InstantSearch和Typesense的JavaScript库,并根据实际情况修改了配置。"><meta name="author" content="biss"><meta name="keywords" content="技术, 建站手札"><link rel="canonical" href="https://blog.biss.click/posts/f287c563/"><link rel="alternate" type="application/rss+xml" title="Bi's Blog RSS" href="https://blog.biss.click/rss.xml"><meta property="og:site_name" content="Bi's Blog"><meta property="og:locale" content="zh_CN"><meta property="og:type" content="article"><meta property="og:title" content="添加typesense搜索 | Bi's Blog"><meta property="og:description" content="这篇文章介绍了如何在博客中集成Typesense搜索引擎,包括安装Typesense、添加数据集以及同步数据的详细步骤。首先,通过命令行安装Typesense并配置相关参数,如API密钥和数据目录。接着,安装必要的npm包,并在Hexo博客中配置搜索功能,指定存储搜索数据的JSON文件路径。然后,编写了一个同步脚本,用于读取XML文件、解析数据、检查或创建集合以及导入数据到Typesense。最后,在博客底部添加了InstantSearch和Typesense的JavaScript库,并根据实际情况修改了配置。"><meta property="og:url" content="https://blog.biss.click/posts/f287c563/"><meta property="og:image" content="https://free.picui.cn/free/2025/08/10/689845496a283.png"><meta property="og:image:alt" content="添加typesense搜索"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="添加typesense搜索 | Bi's Blog"><meta name="twitter:description" content="这篇文章介绍了如何在博客中集成Typesense搜索引擎,包括安装Typesense、添加数据集以及同步数据的详细步骤。首先,通过命令行安装Typesense并配置相关参数,如API密钥和数据目录。接着,安装必要的npm包,并在Hexo博客中配置搜索功能,指定存储搜索数据的JSON文件路径。然后,编写了一个同步脚本,用于读取XML文件、解析数据、检查或创建集合以及导入数据到Typesense。最后,在博客底部添加了InstantSearch和Typesense的JavaScript库,并根据实际情况修改了配置。"><meta name="twitter:image" content="https://free.picui.cn/free/2025/08/10/689845496a283.png"><meta property="article:published_time" content="2026-02-05T13:14:16.000Z"><meta property="article:modified_time" content="2026-02-05T13:14:16.000Z"><meta property="article:author" content="biss"><meta property="article:tag" content="技术"><meta property="article:tag" content="建站手札"><script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"添加typesense搜索","description":"这篇文章介绍了如何在博客中集成Typesense搜索引擎,包括安装Typesense、添加数据集以及同步数据的详细步骤。首先,通过命令行安装Typesense并配置相关参数,如API密钥和数据目录。接着,安装必要的npm包,并在Hexo博客中配置搜索功能,指定存储搜索数据的JSON文件路径。然后,编写了一个同步脚本,用于读取XML文件、解析数据、检查或创建集合以及导入数据到Typesense。最后,在博客底部添加了InstantSearch和Typesense的JavaScript库,并根据实际情况修改了配置。","image":["https://free.picui.cn/free/2025/08/10/689845496a283.png"],"datePublished":"2026-02-05T13:14:16.000Z","dateModified":"2026-02-05T13:14:16.000Z","wordCount":336,"inLanguage":"zh-CN","mainEntityOfPage":{"@type":"WebPage","@id":"https://blog.biss.click/posts/f287c563/"},"author":{"@type":"Person","name":"biss","url":"https://github.com/bishshi"},"publisher":{"@type":"Person","name":"biss","image":"https://free.picui.cn/free/2025/08/10/689845496a283.png"},"keywords":"技术, 建站手札","articleSection":"技术","isPartOf":{"@type":"CreativeWorkSeries","name":"webcustom"}}</script><link rel="icon" href="/images/Bi.ico"><link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/images/apple-touch-icon.png"><meta name="theme-color" content="#f7f5ef" media="(prefers-color-scheme: light)"><meta name="theme-color" content="#0b1014" media="(prefers-color-scheme: dark)"><script defer src="https://umami.biss.click/script.js" data-website-id="3d97b310-b241-4fc6-bc9c-68e317fc7d42"></script><script>window.SITE_CONFIG = {"site":{"url":"https://blog.biss.click","name":"Bi's Blog","description":"Personal blog by biss.","language":"zh-CN","domain":"blog.biss.click","slogan":"好奇心转化为代码的空间。","startTime":"2025-01-01T00:00:00+08:00","copyrightStartYear":2025,"license":"内容采用 CC BY-NC-SA 4.0 协议"},"author":{"name":"biss","email":"bishsh2006@gmail.com","github":"https://github.com/bishshi","avatar":"https://free.picui.cn/free/2025/08/10/689845496a283.png"},"comments":{"envId":"https://comment.biss.click","region":"","script":"https://cdn.jsdelivr.net/npm/twikoo@1.7.7/dist/twikoo.min.js"},"analytics":{"umami":{"script":"https://umami.biss.click/script.js","websiteId":"3d97b310-b241-4fc6-bc9c-68e317fc7d42","statsEndpoint":"https://blogumami.biss.click"}},"talks":{"apiUrl":"https://mm.biss.click/api/echo/page","pageSize":30,"homeLimit":6,"cacheKey":"talksCache","cacheTimeKey":"talksCacheTime","cacheDurationMs":1800000,"fallbackAvatar":"https://free.picui.cn/free/2025/08/10/689845496a283.png"},"search":{"apiKey":"2uxQzk7eD2GBCljdSmIiuU3ause7UK9n","server":{"host":"typesense.biss.click","port":"443","protocol":"https"},"indexName":"blogs","searchParams":{"query_by":"title,content","highlight_full_fields":"title,content","per_page":8,"num_typos":1,"typo_tokens_threshold":1,"prefix":true},"ui":{"maxRetries":10,"retryDelay":100,"animationDuration":300,"maxInitRetries":30,"placeholder":"输入关键词寻找故事..."}},"externalLinks":{"waitSeconds":10,"whitelist":["blog.biss.click","biss.click","github.com","localhost","127.0.0.1"]}};</script><script>
(() => {
const storageKey = 'site-theme';
const media = window.matchMedia('(prefers-color-scheme: dark)');
const getSystemTheme = () => (media.matches ? 'dark' : 'light');
const getSavedTheme = () => {
try {
const theme = localStorage.getItem(storageKey);
return theme === 'dark' || theme === 'light' ? theme : null;
} catch {
return null;
}
};
document.documentElement.dataset.theme = getSavedTheme() || getSystemTheme();
})();
</script><link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/instantsearch.css/themes/reset-min.css"><link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@fancyapps/ui@5.0/dist/fancybox/fancybox.css"><link rel="stylesheet" href="https://cdn.jsdmirror.com/npm/@fortawesome/fontawesome-free@6.7.2/css/all.min.css"><title>添加typesense搜索 | Bi&#39;s Blog</title><link rel="stylesheet" href="/_astro/BaseLayout.DvPRLM9I.css"></head> <body> <header class="site-header"> <nav class="nav"> <a class="brand" href="/" aria-label="Bi's Blog 首页"><span>biss</span><i>.log</i></a> <button class="nav-menu-toggle" type="button" aria-label="打开菜单" aria-expanded="false" data-nav-toggle> <i class="fa-solid fa-bars nav-menu-open" aria-hidden="true"></i> <i class="fa-solid fa-xmark nav-menu-close" aria-hidden="true"></i> </button> <div class="nav-drawer-backdrop" data-nav-backdrop></div> <div class="nav-menu" data-nav-menu> <div class="nav-links nav-center"> <a href="/archives/"><i class="fa-solid fa-box-archive nav-icon" aria-hidden="true"></i><span>归档</span></a><a href="/categories/"><i class="fa-solid fa-folder-open nav-icon" aria-hidden="true"></i><span>分类</span></a><a href="/tags/"><i class="fa-solid fa-tags nav-icon" aria-hidden="true"></i><span>标签</span></a><a href="/shuoshuo/"><i class="fa-regular fa-comments nav-icon" aria-hidden="true"></i><span>说说</span></a><a href="/about/"><i class="fa-solid fa-heart nav-icon" aria-hidden="true"></i><span>关于</span></a><a href="/link/"><i class="fa-solid fa-link nav-icon" aria-hidden="true"></i><span>友链</span></a> </div> <div class="nav-links nav-actions"> <button class="nav-random" type="button" onclick="randomPost()" aria-label="前往随机文章" title="前往随机文章"> <i class="fa-solid fa-shuffle nav-icon" aria-hidden="true"></i> <span>随机文章</span> </button> <button class="nav-search search-typesense-trigger" type="button" aria-label="搜索" title="搜索"> <i class="fa-solid fa-magnifying-glass nav-icon" aria-hidden="true"></i> <span>搜索</span> </button> <button class="nav-console" type="button" aria-label="打开中控台" title="中控台" data-console-open> <i class="fa-solid fa-gauge-high nav-icon" aria-hidden="true"></i> <span>中控台</span> </button> <button class="theme-toggle" type="button" aria-label="切换夜间模式" title="切换夜间模式" data-theme-toggle> <i class="fa-solid fa-sun theme-toggle-sun nav-icon" aria-hidden="true"></i> <i class="fa-solid fa-moon theme-toggle-moon nav-icon" aria-hidden="true"></i> </button> </div> </div> </nav> </header> <div class="control-console" data-console-modal aria-hidden="true"> <div class="control-console-backdrop" data-console-close></div> <section class="control-console-panel" role="dialog" aria-modal="true" aria-labelledby="control-console-title" tabindex="-1"> <header class="control-console-head"> <div> <p>CONTROL CENTER</p> <h2 id="control-console-title">中控台</h2> </div> <button class="control-console-close" type="button" aria-label="关闭中控台" title="关闭" data-console-close> <i class="fa-solid fa-xmark" aria-hidden="true"></i> </button> </header> <div class="control-console-grid"> <a class="control-site-card" href="https://biss.click" target="_blank" rel="noreferrer"> <i class="fa-solid fa-house-chimney" aria-hidden="true"></i> <span> <strong>主站入口</strong> <em>个人主页与常用入口集合</em> </span> <small>biss.click</small> </a><a class="control-site-card" href="https://mm.biss.click" target="_blank" rel="noreferrer"> <i class="fa-regular fa-comments" aria-hidden="true"></i> <span> <strong>说说动态</strong> <em>短句、碎片记录和近况更新</em> </span> <small>mm.biss.click</small> </a><a class="control-site-card" href="https://comment.biss.click" target="_blank" rel="noreferrer"> <i class="fa-solid fa-message" aria-hidden="true"></i> <span> <strong>评论系统</strong> <em>Twikoo 评论服务与访客互动</em> </span> <small>comment.biss.click</small> </a><a class="control-site-card" href="https://umami.biss.click" target="_blank" rel="noreferrer"> <i class="fa-solid fa-chart-line" aria-hidden="true"></i> <span> <strong>站点统计</strong> <em>Umami 访问统计与站点数据</em> </span> <small>umami.biss.click</small> </a> </div> <div class="control-console-info" aria-label="站点信息"> <section> <span>当前站点</span> <strong>blog.biss.click</strong> </section> <section> <span>站点作者</span> <strong>biss</strong> </section> <section> <span>运行起点</span> <strong>2025-01-01</strong> </section> </div> </section> </div> <main> <div class="reading-progress" aria-hidden="true"><span data-reading-progress></span></div> <aside class="reading-resume" data-reading-resume hidden aria-live="polite"> <div class="reading-resume-icon" aria-hidden="true"><i class="fa-solid fa-book-open"></i></div> <div> <strong>继续上次阅读?</strong> <p>你上次读到了 <span data-reading-resume-percent>--</span></p> </div> <div class="reading-resume-actions"> <button type="button" data-reading-continue>继续阅读</button> <button type="button" data-reading-restart>从头开始</button> </div> </aside> <section class="post-layout"> <article class="article post-article"> <header class="article-header"> <div class="article-header-content"> <div class="article-date-line"> <time datetime="2026-02-05T13:14:16.000Z">发布于 2026/02/05</time> </div> <h1>添加typesense搜索</h1> <div class="meta-list"> <a href="/categories/tech/">技术</a><a href="/categories/site-notes/">建站手札</a> </div> </div> </header> <section class="article-summary-card" aria-label="文章导读"> <div class="article-summary-header"> <span class="article-summary-brand"> <span class="article-summary-icon" aria-hidden="true"></span>
文章导读
</span> <span class="article-summary-model">摘要由 Qwen 生成</span> </div> <p class="article-summary-content"> <span class="article-summary-text">这篇文章介绍了如何在博客中集成Typesense搜索引擎,包括安装Typesense、添加数据集以及同步数据的详细步骤。首先,通过命令行安装Typesense并配置相关参数,如API密钥和数据目录。接着,安装必要的npm包,并在Hexo博客中配置搜索功能,指定存储搜索数据的JSON文件路径。然后,编写了一个同步脚本,用于读取XML文件、解析数据、检查或创建集合以及导入数据到Typesense。最后,在博客底部添加了InstantSearch和Typesense的JavaScript库,并根据实际情况修改了配置。</span> </p> </section> <section class="post-series" aria-labelledby="post-series-title"> <header class="post-series-head"> <div> <span>ARTICLE SERIES</span> <h2 id="post-series-title">博客定制手记</h2> </div> <strong>第 13 / 14 篇</strong> </header> <ol class="post-series-list"> <li> <a href="/posts/ad244066/"> <span>01</span> <strong>配置说说页面</strong> </a> </li><li> <a href="/posts/da17d00/"> <span>02</span> <strong>在侧边栏中添加欢迎信息</strong> </a> </li><li> <a href="/posts/8bdb35fb/"> <span>03</span> <strong>自定义右键菜单</strong> </a> </li><li> <a href="/posts/a6ab0925/"> <span>04</span> <strong>利用插件自定义页脚菜单</strong> </a> </li><li> <a href="/posts/41b9aff7/"> <span>05</span> <strong>博客添加AI总结</strong> </a> </li><li> <a href="/posts/b5866b9e/"> <span>06</span> <strong>利用 SiteMap 随机访问站内页面</strong> </a> </li><li> <a href="/posts/3e61a389/"> <span>07</span> <strong>自定义导航栏</strong> </a> </li><li> <a href="/posts/33249733/"> <span>08</span> <strong>自定义分类条</strong> </a> </li><li> <a href="/posts/c6143ad3/"> <span>09</span> <strong>自定义页脚(新)</strong> </a> </li><li> <a href="/posts/ce1ec3fe/"> <span>10</span> <strong>使用GitHub推送Hexo到服务器</strong> </a> </li><li> <a href="/posts/5ed2f1e6/"> <span>11</span> <strong>在侧边栏添加日历和倒计时</strong> </a> </li><li> <a href="/posts/b5601a7e/"> <span>12</span> <strong>自定义字体</strong> </a> </li><li class="is-current"> <a href="/posts/f287c563/" aria-current="page"> <span>13</span> <strong>添加typesense搜索</strong> </a> </li><li> <a href="/posts/7e921903/"> <span>14</span> <strong>添加网站左上角菜单</strong> </a> </li> </ol> <nav class="post-series-nav" aria-label="系列文章导航"> <a href="/posts/b5601a7e/" rel="prev"><span>上一篇</span><strong>自定义字体</strong></a> <a href="/posts/7e921903/" rel="next"><span>下一篇</span><strong>添加网站左上角菜单</strong></a> </nav> </section> <aside class="butterfly-note butterfly-note--warning article-outdated-note" aria-label="文章时效性提示" data-freshness-at="2026-02-05T13:14:16.000Z" data-has-new-version="false" hidden> <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> <div class="butterfly-note__content"> <p>
注意:本文最后维护于 2026/02/05,距今已超过一年,部分内容可能已经过时,请结合最新资料判断。
</p> </div> </aside> <div class="prose" data-toc="[{&quot;depth&quot;:1,&quot;text&quot;:&quot;安装typesense&quot;,&quot;id&quot;:&quot;安装typesense&quot;},{&quot;depth&quot;:1,&quot;text&quot;:&quot;添加数据集&quot;,&quot;id&quot;:&quot;添加数据集&quot;},{&quot;depth&quot;:1,&quot;text&quot;:&quot;创建只读key&quot;,&quot;id&quot;:&quot;创建只读key&quot;},{&quot;depth&quot;:1,&quot;text&quot;:&quot;博客添加搜索&quot;,&quot;id&quot;:&quot;博客添加搜索&quot;},{&quot;depth&quot;:1,&quot;text&quot;:&quot;注意&quot;,&quot;id&quot;:&quot;注意&quot;}]"> <div class="butterfly-note butterfly-note--warning butterfly-note--simple"><i class="fa-solid fa-circle-info" aria-hidden="true"></i><div class="butterfly-note__content"><p>注意:本文为早期 Hexo / Butterfly 魔改记录,本站现已迁移到 Astro,相关代码、路径或插件可能已经失效,仅作历史参考。</p></div></div>
<p>最近在构建班级博客,用<code>ghost cms</code>,在构建搜索时发现了typesense,所以把他移植到这个博客上。</p>
<h1 id="安装typesense">安装typesense</h1>
<p>直接用<code>docker-compose</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="yaml"><code><span class="line"></span>
<span class="line"><span style="color:#85E89D">services</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D"> typesense</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D"> image</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">typesense/typesense:30.1</span></span>
<span class="line"><span style="color:#85E89D"> restart</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">always</span></span>
<span class="line"><span style="color:#85E89D"> ports</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8"> - </span><span style="color:#9ECBFF">"8108:8108"</span></span>
<span class="line"><span style="color:#85E89D"> volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8"> - </span><span style="color:#9ECBFF">./typesense-data:/data</span></span>
<span class="line"><span style="color:#85E89D"> command</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'--data-dir /data --api-key=填写key --enable-cors'</span></span>
<span class="line"></span></code></pre>
<p>然后就是反向代理之类的,不过多写了。</p>
<h1 id="添加数据集">添加数据集</h1>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:#6A737D"># 先安装库</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> hexo-generator-search</span></span>
<span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> typesense</span><span style="color:#9ECBFF"> xml2js</span></span></code></pre>
<p>然后在<code>config.yml</code>配置(就是把文章生成json):</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="yml"><code><span class="line"><span style="color:#85E89D">search</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D"> path</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">search.json</span></span>
<span class="line"><span style="color:#85E89D"> field</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">post</span></span>
<span class="line"><span style="color:#85E89D"> content</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span></code></pre>
<p>创建一个数据同步脚本<code>sync_typesense.js</code>:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> Typesense</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> require</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'typesense'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> fs</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> require</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'fs'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> xml2js</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> require</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'xml2js'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// --- 配置区域 ---</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> CONFIG</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> apiKey: </span><span style="color:#9ECBFF">'你的Admin-API-Key'</span><span style="color:#E1E4E8">, </span><span style="color:#6A737D">// 必须是 Admin Key</span></span>
<span class="line"><span style="color:#E1E4E8"> host: </span><span style="color:#9ECBFF">'你的Typesense主机地址'</span><span style="color:#E1E4E8">, </span></span>
<span class="line"><span style="color:#E1E4E8"> port: </span><span style="color:#79B8FF">443</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> protocol: </span><span style="color:#9ECBFF">'https'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> collectionName: </span><span style="color:#9ECBFF">'blogs'</span></span>
<span class="line"><span style="color:#E1E4E8">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> client</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#E1E4E8"> Typesense.</span><span style="color:#B392F0">Client</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#9ECBFF"> 'nodes'</span><span style="color:#E1E4E8">: [{ </span><span style="color:#9ECBFF">'host'</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.host, </span><span style="color:#9ECBFF">'port'</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.port, </span><span style="color:#9ECBFF">'protocol'</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.protocol }],</span></span>
<span class="line"><span style="color:#9ECBFF"> 'apiKey'</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.apiKey,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'connectionTimeoutSeconds'</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">5</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> sync</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#F97583"> try</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#6A737D"> // 1. 读取并解析 XML</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> xml</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> fs.</span><span style="color:#B392F0">readFileSync</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'./public/search.xml'</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">'utf8'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> parser</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#E1E4E8"> xml2js.</span><span style="color:#B392F0">Parser</span><span style="color:#E1E4E8">({ explicitArray: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8"> });</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> result</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> parser.</span><span style="color:#B392F0">parseStringPromise</span><span style="color:#E1E4E8">(xml);</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#6A737D"> // 提取文章列表 (处理单篇文章和多篇文章的情况)</span></span>
<span class="line"><span style="color:#F97583"> let</span><span style="color:#E1E4E8"> entries </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> result.search.entry;</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">Array.</span><span style="color:#B392F0">isArray</span><span style="color:#E1E4E8">(entries)) entries </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> [entries];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // 格式化数据以适配 Typesense</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> documents</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> entries.</span><span style="color:#B392F0">map</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> ({</span></span>
<span class="line"><span style="color:#E1E4E8"> title: post.title,</span></span>
<span class="line"><span style="color:#E1E4E8"> url: post.url,</span></span>
<span class="line"><span style="color:#E1E4E8"> content: post.content,</span></span>
<span class="line"><span style="color:#E1E4E8"> categories: post.categories </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> (Array.</span><span style="color:#B392F0">isArray</span><span style="color:#E1E4E8">(post.categories.category) </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> post.categories.category </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> [post.categories.category]) </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> [],</span></span>
<span class="line"><span style="color:#E1E4E8"> tags: post.tags </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> (Array.</span><span style="color:#B392F0">isArray</span><span style="color:#E1E4E8">(post.tags.tag) </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> post.tags.tag </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> [post.tags.tag]) </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> [],</span></span>
<span class="line"><span style="color:#E1E4E8"> }));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // 2. 检查或创建 Collection (Schema)</span></span>
<span class="line"><span style="color:#F97583"> try</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> client.</span><span style="color:#B392F0">collections</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.collectionName).</span><span style="color:#B392F0">retrieve</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> } </span><span style="color:#F97583">catch</span><span style="color:#E1E4E8"> (err) {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> schema</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> name: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.collectionName,</span></span>
<span class="line"><span style="color:#E1E4E8"> fields: [</span></span>
<span class="line"><span style="color:#E1E4E8"> { name: </span><span style="color:#9ECBFF">'title'</span><span style="color:#E1E4E8">, type: </span><span style="color:#9ECBFF">'string'</span><span style="color:#E1E4E8">, locale: </span><span style="color:#9ECBFF">'zh'</span><span style="color:#E1E4E8">},</span></span>
<span class="line"><span style="color:#E1E4E8"> { name: </span><span style="color:#9ECBFF">'content'</span><span style="color:#E1E4E8">, type: </span><span style="color:#9ECBFF">'string'</span><span style="color:#E1E4E8">, locale: </span><span style="color:#9ECBFF">'zh'</span><span style="color:#E1E4E8">},</span></span>
<span class="line"><span style="color:#E1E4E8"> { name: </span><span style="color:#9ECBFF">'url'</span><span style="color:#E1E4E8">, type: </span><span style="color:#9ECBFF">'string'</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> { name: </span><span style="color:#9ECBFF">'categories'</span><span style="color:#E1E4E8">, type: </span><span style="color:#9ECBFF">'string[]'</span><span style="color:#E1E4E8">, facet: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> { name: </span><span style="color:#9ECBFF">'tags'</span><span style="color:#E1E4E8">, type: </span><span style="color:#9ECBFF">'string[]'</span><span style="color:#E1E4E8">, facet: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> ]</span></span>
<span class="line"><span style="color:#E1E4E8"> };</span></span>
<span class="line"><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> client.</span><span style="color:#B392F0">collections</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">create</span><span style="color:#E1E4E8">(schema);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Collection created!'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // 3. 导入数据 (使用 upsert 模式:存在则更新,不存在则创建)</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">`Syncing ${</span><span style="color:#E1E4E8">documents</span><span style="color:#9ECBFF">.</span><span style="color:#79B8FF">length</span><span style="color:#9ECBFF">} posts to Typesense...`</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> client.</span><span style="color:#B392F0">collections</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.collectionName).</span><span style="color:#B392F0">documents</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">import</span><span style="color:#E1E4E8">(documents, { action: </span><span style="color:#9ECBFF">'upsert'</span><span style="color:#E1E4E8"> });</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Sync complete!'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> } </span><span style="color:#F97583">catch</span><span style="color:#E1E4E8"> (error) {</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Sync failed:'</span><span style="color:#E1E4E8">, error);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#B392F0">sync</span><span style="color:#E1E4E8">();</span></span></code></pre>
<p>然后运行<code>node sync_typesense.js</code></p>
<h1 id="创建只读key">创建只读key</h1>
<p>把下面代码存储成js,node运行就行。</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> http</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> require</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'https'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> data</span><span style="color:#F97583"> =</span><span style="color:#79B8FF"> JSON</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">stringify</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#9ECBFF"> "description"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"Public search only key"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF"> "actions"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"documents:search"</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#9ECBFF"> "collections"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"blogs"</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> options</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> hostname: </span><span style="color:#9ECBFF">''</span><span style="color:#E1E4E8">, </span><span style="color:#6A737D">// 不要带 https://</span></span>
<span class="line"><span style="color:#E1E4E8"> port: </span><span style="color:#79B8FF">443</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> path: </span><span style="color:#9ECBFF">'/keys'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> method: </span><span style="color:#9ECBFF">'POST'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> headers: {</span></span>
<span class="line"><span style="color:#9ECBFF"> 'X-TYPESENSE-API-KEY'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'你的admin key'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'Content-Type'</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">'application/json'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#9ECBFF"> 'Content-Length'</span><span style="color:#E1E4E8">: data.</span><span style="color:#79B8FF">length</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> req</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> http.</span><span style="color:#B392F0">request</span><span style="color:#E1E4E8">(options, </span><span style="color:#FFAB70">res</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> res.</span><span style="color:#B392F0">on</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'data'</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">d</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> { process.stdout.</span><span style="color:#B392F0">write</span><span style="color:#E1E4E8">(d); });</span></span>
<span class="line"><span style="color:#E1E4E8">});</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">req.</span><span style="color:#B392F0">on</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'error'</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">error</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> { console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(error); });</span></span>
<span class="line"><span style="color:#E1E4E8">req.</span><span style="color:#B392F0">write</span><span style="color:#E1E4E8">(data);</span></span>
<span class="line"><span style="color:#E1E4E8">req.</span><span style="color:#B392F0">end</span><span style="color:#E1E4E8">();</span></span></code></pre>
<h1 id="博客添加搜索">博客添加搜索</h1>
<p><code>config.yaml</code>inject bottom添加:</p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="yaml"><code><span class="line"><span style="color:#E1E4E8"> - </span><span style="color:#9ECBFF">&#x3C;script src="https://cdn.jsdmirror.com/npm/instantsearch.js@4.56.0">&#x3C;/script></span></span>
<span class="line"><span style="color:#E1E4E8"> - </span><span style="color:#9ECBFF">&#x3C;script src="https://cdn.jsdmirror.com/npm/typesense-instantsearch-adapter@2.7.0/dist/typesense-instantsearch-adapter.min.js">&#x3C;/script></span></span></code></pre>
<p>为了方便,我直接修改了<code>\themes\butterfly\source\js\search\local_search.js</code></p>
<pre class="astro-code github-dark" style="background-color:#24292e;color:#e1e4e8; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;" tabindex="0" data-language="js"><code><span class="line"><span style="color:#E1E4E8">(</span><span style="color:#F97583">function</span><span style="color:#E1E4E8"> () {</span></span>
<span class="line"><span style="color:#9ECBFF"> 'use strict'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // 配置区域 - 请根据实际情况修改</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> CONFIG</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> apiKey: </span><span style="color:#9ECBFF">""</span><span style="color:#E1E4E8">, </span><span style="color:#6A737D">// ⚠️ 建议使用 Search-Only API Key</span></span>
<span class="line"><span style="color:#E1E4E8"> server: {</span></span>
<span class="line"><span style="color:#E1E4E8"> host: </span><span style="color:#9ECBFF">"host"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> port: </span><span style="color:#9ECBFF">"443"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> protocol: </span><span style="color:#9ECBFF">"https"</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> indexName: </span><span style="color:#9ECBFF">"blogs"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> searchParams: {</span></span>
<span class="line"><span style="color:#E1E4E8"> query_by: </span><span style="color:#9ECBFF">"title,content"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> highlight_full_fields: </span><span style="color:#9ECBFF">"title,content"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> per_page: </span><span style="color:#79B8FF">8</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> num_typos: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> typo_tokens_threshold: </span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> prefix: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> ui: {</span></span>
<span class="line"><span style="color:#E1E4E8"> maxRetries: </span><span style="color:#79B8FF">10</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> retryDelay: </span><span style="color:#79B8FF">100</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> animationDuration: </span><span style="color:#79B8FF">300</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> };</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // 状态管理</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#F97583"> let</span><span style="color:#E1E4E8"> searchInstance </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> null</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> let</span><span style="color:#E1E4E8"> isInitialized </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> false</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> let</span><span style="color:#E1E4E8"> isSearchOpen </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> false</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> let</span><span style="color:#E1E4E8"> initRetryCount </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> MAX_INIT_RETRIES</span><span style="color:#F97583"> =</span><span style="color:#79B8FF"> 30</span><span style="color:#E1E4E8">; </span><span style="color:#6A737D">// 最多重试30次 (3秒)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // 错误提示函数</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#F97583"> function</span><span style="color:#B392F0"> showErrorMessage</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> hitsContainer</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">getElementById</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'hits'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">hitsContainer) </span><span style="color:#F97583">return</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#E1E4E8"> hitsContainer.innerHTML </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div class="ts-empty">'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div style="color: #f44336;">&#x3C;i class="fas fa-exclamation-triangle" style="font-size: 3rem;">&#x3C;/i>&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div style="font-size: 1.1rem; font-weight: bold; margin: 15px 0;">搜索服务加载失败&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div style="font-size: 0.9rem; color: #666; line-height: 1.8;">'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;p>依赖库未能正确加载,请检查以下配置:&#x3C;/p>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;ol style="text-align: left; max-width: 500px; margin: 15px auto;">'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;li>确认已在 &#x3C;code>_config.butterfly.yml&#x3C;/code> 中正确引入依赖&#x3C;/li>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;li>检查 JS 文件加载顺序(先 instantsearch.js,再 adapter&#x3C;/li>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;li>尝试更换 CDN 或使用本地文件&#x3C;/li>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;li>打开浏览器控制台查看详细错误信息&#x3C;/li>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;/ol>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div style="margin-top: 20px;">'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;button onclick="location.reload()" style="padding: 10px 20px; background: #49b1f5; color: white; border: none; border-radius: 5px; cursor: pointer;">重新加载页面&#x3C;/button>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;/div>'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // 1. 动态插入 HTML 结构</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> searchHTML</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> `</span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;div id="typesense-search-mask" class="ts-mask" style="display:none;"></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;div id="typesense-search-container" class="ts-container"></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;div class="ts-header"></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;span class="ts-title"></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;i class="fas fa-search">&#x3C;/i> 本站搜索</span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;/span></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;span id="close-typesense" class="ts-close" aria-label="关闭搜索">&#x26;times;&#x3C;/span></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;/div></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;div id="searchbox">&#x3C;/div></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;div id="stats" class="ts-stats">&#x3C;/div></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;div id="hits" class="ts-hits">&#x3C;/div></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;div id="pagination" class="ts-pagination">&#x3C;/div></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;div class="ts-footer"></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;small>Search powered by &#x3C;strong>Typesense&#x3C;/strong>&#x3C;/small></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;/div></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;/div></span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;/div></span></span>
<span class="line"></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;style></span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-mask {</span></span>
<span class="line"><span style="color:#9ECBFF"> position: fixed;</span></span>
<span class="line"><span style="color:#9ECBFF"> top: 0;</span></span>
<span class="line"><span style="color:#9ECBFF"> left: 0;</span></span>
<span class="line"><span style="color:#9ECBFF"> width: 100%;</span></span>
<span class="line"><span style="color:#9ECBFF"> height: 100%;</span></span>
<span class="line"><span style="color:#9ECBFF"> background: rgba(0, 0, 0, 0.7);</span></span>
<span class="line"><span style="color:#9ECBFF"> z-index: 10000;</span></span>
<span class="line"><span style="color:#9ECBFF"> backdrop-filter: blur(5px);</span></span>
<span class="line"><span style="color:#9ECBFF"> -webkit-backdrop-filter: blur(5px);</span></span>
<span class="line"><span style="color:#9ECBFF"> opacity: 0;</span></span>
<span class="line"><span style="color:#9ECBFF"> transition: opacity 300ms ease;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-mask.active { opacity: 1; }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-container {</span></span>
<span class="line"><span style="color:#9ECBFF"> margin: 5% auto;</span></span>
<span class="line"><span style="color:#9ECBFF"> width: 90%;</span></span>
<span class="line"><span style="color:#9ECBFF"> max-width: 650px;</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--search-bg, var(--card-bg, #fff));</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 25px;</span></span>
<span class="line"><span style="color:#9ECBFF"> border-radius: 12px;</span></span>
<span class="line"><span style="color:#9ECBFF"> box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);</span></span>
<span class="line"><span style="color:#9ECBFF"> position: relative;</span></span>
<span class="line"><span style="color:#9ECBFF"> z-index: 10001;</span></span>
<span class="line"><span style="color:#9ECBFF"> transform: translateY(-50px);</span></span>
<span class="line"><span style="color:#9ECBFF"> opacity: 0;</span></span>
<span class="line"><span style="color:#9ECBFF"> transition: all 300ms ease;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-mask.active .ts-container {</span></span>
<span class="line"><span style="color:#9ECBFF"> transform: translateY(0);</span></span>
<span class="line"><span style="color:#9ECBFF"> opacity: 1;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-header {</span></span>
<span class="line"><span style="color:#9ECBFF"> display: flex;</span></span>
<span class="line"><span style="color:#9ECBFF"> justify-content: space-between;</span></span>
<span class="line"><span style="color:#9ECBFF"> align-items: center;</span></span>
<span class="line"><span style="color:#9ECBFF"> margin-bottom: 20px;</span></span>
<span class="line"><span style="color:#9ECBFF"> border-bottom: 2px solid var(--text-highlight-color, #49b1f5);</span></span>
<span class="line"><span style="color:#9ECBFF"> padding-bottom: 10px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-title {</span></span>
<span class="line"><span style="color:#9ECBFF"> font-size: 1.2rem;</span></span>
<span class="line"><span style="color:#9ECBFF"> font-weight: bold;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--text-highlight-color, #49b1f5);</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-close {</span></span>
<span class="line"><span style="color:#9ECBFF"> cursor: pointer;</span></span>
<span class="line"><span style="color:#9ECBFF"> font-size: 28px;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--font-color, #333);</span></span>
<span class="line"><span style="color:#9ECBFF"> line-height: 1;</span></span>
<span class="line"><span style="color:#9ECBFF"> transition: color 0.2s, transform 0.2s;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-close:hover {</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--text-highlight-color, #49b1f5);</span></span>
<span class="line"><span style="color:#9ECBFF"> transform: scale(1.1);</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ais-SearchBox-input {</span></span>
<span class="line"><span style="color:#9ECBFF"> position: relative;</span></span>
<span class="line"><span style="color:#9ECBFF"> z-index: 10002;</span></span>
<span class="line"><span style="color:#9ECBFF"> cursor: text;</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 12px 40px 12px 15px !important;</span></span>
<span class="line"><span style="color:#9ECBFF"> border-radius: 8px !important;</span></span>
<span class="line"><span style="color:#9ECBFF"> border: 2px solid #eee !important;</span></span>
<span class="line"><span style="color:#9ECBFF"> width: 100%;</span></span>
<span class="line"><span style="color:#9ECBFF"> outline: none;</span></span>
<span class="line"><span style="color:#9ECBFF"> transition: border-color 0.3s, box-shadow 0.3s;</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--card-bg, #fff);</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--font-color, #333);</span></span>
<span class="line"><span style="color:#9ECBFF"> font-size: 1rem;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ais-SearchBox-input:focus {</span></span>
<span class="line"><span style="color:#9ECBFF"> border-color: var(--text-highlight-color, #49b1f5) !important;</span></span>
<span class="line"><span style="color:#9ECBFF"> box-shadow: 0 0 0 3px rgba(73, 177, 245, 0.1);</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-stats {</span></span>
<span class="line"><span style="color:#9ECBFF"> margin: 10px 0;</span></span>
<span class="line"><span style="color:#9ECBFF"> font-size: 0.85rem;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--font-color, #666);</span></span>
<span class="line"><span style="color:#9ECBFF"> opacity: 0.8;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-hits {</span></span>
<span class="line"><span style="color:#9ECBFF"> max-height: 55vh;</span></span>
<span class="line"><span style="color:#9ECBFF"> overflow-y: auto;</span></span>
<span class="line"><span style="color:#9ECBFF"> margin-top: 15px;</span></span>
<span class="line"><span style="color:#9ECBFF"> padding-right: 5px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-hits::-webkit-scrollbar { width: 6px; }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-hits::-webkit-scrollbar-track {</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--card-bg, #f1f1f1);</span></span>
<span class="line"><span style="color:#9ECBFF"> border-radius: 10px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-hits::-webkit-scrollbar-thumb {</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--text-highlight-color, #49b1f5);</span></span>
<span class="line"><span style="color:#9ECBFF"> border-radius: 10px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-empty {</span></span>
<span class="line"><span style="color:#9ECBFF"> text-align: center;</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 40px 20px;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--font-color, #999);</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-empty i {</span></span>
<span class="line"><span style="color:#9ECBFF"> font-size: 3rem;</span></span>
<span class="line"><span style="color:#9ECBFF"> margin-bottom: 15px;</span></span>
<span class="line"><span style="color:#9ECBFF"> opacity: 0.3;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-empty code {</span></span>
<span class="line"><span style="color:#9ECBFF"> background: #f5f5f5;</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 2px 6px;</span></span>
<span class="line"><span style="color:#9ECBFF"> border-radius: 3px;</span></span>
<span class="line"><span style="color:#9ECBFF"> font-family: monospace;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: #e91e63;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-empty ol {</span></span>
<span class="line"><span style="color:#9ECBFF"> padding-left: 20px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-empty li {</span></span>
<span class="line"><span style="color:#9ECBFF"> margin: 8px 0;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-result-item {</span></span>
<span class="line"><span style="color:#9ECBFF"> border-radius: 8px;</span></span>
<span class="line"><span style="color:#9ECBFF"> transition: all 0.2s ease;</span></span>
<span class="line"><span style="color:#9ECBFF"> margin-bottom: 10px;</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 15px;</span></span>
<span class="line"><span style="color:#9ECBFF"> border: 1px solid transparent;</span></span>
<span class="line"><span style="color:#9ECBFF"> text-decoration: none;</span></span>
<span class="line"><span style="color:#9ECBFF"> display: block;</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--card-bg, #fff);</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-result-item:hover {</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--text-bg-hover, rgba(73, 177, 245, 0.05));</span></span>
<span class="line"><span style="color:#9ECBFF"> border-color: var(--text-highlight-color, #49b1f5);</span></span>
<span class="line"><span style="color:#9ECBFF"> transform: translateX(5px);</span></span>
<span class="line"><span style="color:#9ECBFF"> box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-result-title {</span></span>
<span class="line"><span style="color:#9ECBFF"> font-weight: bold;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--text-highlight-color, #49b1f5);</span></span>
<span class="line"><span style="color:#9ECBFF"> font-size: 1.1rem;</span></span>
<span class="line"><span style="color:#9ECBFF"> margin-bottom: 8px;</span></span>
<span class="line"><span style="color:#9ECBFF"> display: block;</span></span>
<span class="line"><span style="color:#9ECBFF"> line-height: 1.4;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-result-content {</span></span>
<span class="line"><span style="color:#9ECBFF"> font-size: 0.9rem;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--font-color, #666);</span></span>
<span class="line"><span style="color:#9ECBFF"> line-height: 1.6;</span></span>
<span class="line"><span style="color:#9ECBFF"> opacity: 0.85;</span></span>
<span class="line"><span style="color:#9ECBFF"> display: -webkit-box;</span></span>
<span class="line"><span style="color:#9ECBFF"> -webkit-line-clamp: 2;</span></span>
<span class="line"><span style="color:#9ECBFF"> -webkit-box-orient: vertical;</span></span>
<span class="line"><span style="color:#9ECBFF"> overflow: hidden;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-result-item mark {</span></span>
<span class="line"><span style="color:#9ECBFF"> background: #ffeb3b;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: #000;</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 2px 4px;</span></span>
<span class="line"><span style="color:#9ECBFF"> border-radius: 3px;</span></span>
<span class="line"><span style="color:#9ECBFF"> font-weight: 500;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-pagination {</span></span>
<span class="line"><span style="color:#9ECBFF"> margin-top: 20px;</span></span>
<span class="line"><span style="color:#9ECBFF"> display: flex;</span></span>
<span class="line"><span style="color:#9ECBFF"> justify-content: center;</span></span>
<span class="line"><span style="color:#9ECBFF"> gap: 5px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ais-Pagination-list {</span></span>
<span class="line"><span style="color:#9ECBFF"> display: flex;</span></span>
<span class="line"><span style="color:#9ECBFF"> list-style: none;</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 0;</span></span>
<span class="line"><span style="color:#9ECBFF"> margin: 0;</span></span>
<span class="line"><span style="color:#9ECBFF"> gap: 5px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ais-Pagination-link {</span></span>
<span class="line"><span style="color:#9ECBFF"> display: block;</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 8px 12px;</span></span>
<span class="line"><span style="color:#9ECBFF"> border: 1px solid #ddd;</span></span>
<span class="line"><span style="color:#9ECBFF"> border-radius: 5px;</span></span>
<span class="line"><span style="color:#9ECBFF"> color: var(--font-color, #333);</span></span>
<span class="line"><span style="color:#9ECBFF"> text-decoration: none;</span></span>
<span class="line"><span style="color:#9ECBFF"> transition: all 0.2s;</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--card-bg, #fff);</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ais-Pagination-link:hover {</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--text-highlight-color, #49b1f5);</span></span>
<span class="line"><span style="color:#9ECBFF"> color: #fff;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ais-Pagination-item--selected .ais-Pagination-link {</span></span>
<span class="line"><span style="color:#9ECBFF"> background: var(--text-highlight-color, #49b1f5);</span></span>
<span class="line"><span style="color:#9ECBFF"> color: #fff;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-footer {</span></span>
<span class="line"><span style="color:#9ECBFF"> text-align: right;</span></span>
<span class="line"><span style="color:#9ECBFF"> margin-top: 15px;</span></span>
<span class="line"><span style="color:#9ECBFF"> border-top: 1px solid var(--border-color, #eee);</span></span>
<span class="line"><span style="color:#9ECBFF"> padding-top: 10px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> @media (max-width: 768px) {</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-container {</span></span>
<span class="line"><span style="color:#9ECBFF"> margin: 10px;</span></span>
<span class="line"><span style="color:#9ECBFF"> width: calc(100% - 20px);</span></span>
<span class="line"><span style="color:#9ECBFF"> padding: 20px 15px;</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> .ts-hits { max-height: 50vh; }</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> [data-theme="dark"] .ts-mask,</span></span>
<span class="line"><span style="color:#9ECBFF"> .dark-mode .ts-mask {</span></span>
<span class="line"><span style="color:#9ECBFF"> background: rgba(0, 0, 0, 0.85);</span></span>
<span class="line"><span style="color:#9ECBFF"> }</span></span>
<span class="line"><span style="color:#9ECBFF"> &#x3C;/style></span></span>
<span class="line"><span style="color:#9ECBFF"> `</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> document.body.</span><span style="color:#B392F0">insertAdjacentHTML</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'beforeend'</span><span style="color:#E1E4E8">, searchHTML);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> mask</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">getElementById</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'typesense-search-mask'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> closeBtn</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">getElementById</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'close-typesense'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> container</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">getElementById</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'typesense-search-container'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // 搜索控制</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#F97583"> function</span><span style="color:#B392F0"> openSearch</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (isSearchOpen) </span><span style="color:#F97583">return</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> isSearchOpen </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> true</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> mask.style.display </span><span style="color:#F97583">=</span><span style="color:#9ECBFF"> 'block'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> void</span><span style="color:#E1E4E8"> mask.offsetWidth;</span></span>
<span class="line"><span style="color:#E1E4E8"> mask.classList.</span><span style="color:#B392F0">add</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'active'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> document.body.style.overflow </span><span style="color:#F97583">=</span><span style="color:#9ECBFF"> 'hidden'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isInitialized) {</span></span>
<span class="line"><span style="color:#B392F0"> initTypesense</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#B392F0"> focusSearchInput</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> function</span><span style="color:#B392F0"> closeSearch</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isSearchOpen) </span><span style="color:#F97583">return</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> isSearchOpen </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> false</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> mask.classList.</span><span style="color:#B392F0">remove</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'active'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#B392F0"> setTimeout</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">function</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#E1E4E8"> mask.style.display </span><span style="color:#F97583">=</span><span style="color:#9ECBFF"> 'none'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> document.body.style.overflow </span><span style="color:#F97583">=</span><span style="color:#9ECBFF"> ''</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }, </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.ui.animationDuration);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> function</span><span style="color:#B392F0"> focusSearchInput</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">retryCount</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8"> retryCount </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> retryCount </span><span style="color:#F97583">||</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> input</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">querySelector</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'.ais-SearchBox-input'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (input) {</span></span>
<span class="line"><span style="color:#E1E4E8"> input.</span><span style="color:#B392F0">focus</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> input.</span><span style="color:#B392F0">select</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> } </span><span style="color:#F97583">else</span><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (retryCount </span><span style="color:#F97583">&#x3C;</span><span style="color:#79B8FF"> CONFIG</span><span style="color:#E1E4E8">.ui.maxRetries) {</span></span>
<span class="line"><span style="color:#B392F0"> setTimeout</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">function</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#B392F0"> focusSearchInput</span><span style="color:#E1E4E8">(retryCount </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> }, </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.ui.retryDelay);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // 事件监听</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">addEventListener</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'click'</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">e</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (e.target.</span><span style="color:#B392F0">closest</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'.search-typesense-trigger'</span><span style="color:#E1E4E8">)) {</span></span>
<span class="line"><span style="color:#E1E4E8"> e.</span><span style="color:#B392F0">preventDefault</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#B392F0"> openSearch</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> closeBtn.</span><span style="color:#B392F0">addEventListener</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'click'</span><span style="color:#E1E4E8">, closeSearch);</span></span>
<span class="line"><span style="color:#E1E4E8"> mask.</span><span style="color:#B392F0">addEventListener</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'click'</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">e</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (e.target </span><span style="color:#F97583">===</span><span style="color:#E1E4E8"> mask) </span><span style="color:#B392F0">closeSearch</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"><span style="color:#E1E4E8"> container.</span><span style="color:#B392F0">addEventListener</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'click'</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">e</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8"> e.</span><span style="color:#B392F0">stopPropagation</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"><span style="color:#E1E4E8"> window.</span><span style="color:#B392F0">addEventListener</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'keydown'</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">e</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (e.key </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> 'Escape'</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> isSearchOpen) {</span></span>
<span class="line"><span style="color:#B392F0"> closeSearch</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> ((e.ctrlKey </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> e.metaKey) </span><span style="color:#F97583">&#x26;&#x26;</span><span style="color:#E1E4E8"> e.key </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> 'k'</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8"> e.</span><span style="color:#B392F0">preventDefault</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#B392F0"> openSearch</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // Typesense 初始化(带重试限制)</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#F97583"> function</span><span style="color:#B392F0"> initTypesense</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (isInitialized </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> searchInstance) {</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">warn</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Typesense 搜索已初始化'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> var</span><span style="color:#E1E4E8"> instantsearchLoaded </span><span style="color:#F97583">=</span><span style="color:#F97583"> typeof</span><span style="color:#E1E4E8"> instantsearch </span><span style="color:#F97583">!==</span><span style="color:#9ECBFF"> 'undefined'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> var</span><span style="color:#E1E4E8"> adapterLoaded </span><span style="color:#F97583">=</span><span style="color:#F97583"> typeof</span><span style="color:#E1E4E8"> TypesenseInstantSearchAdapter </span><span style="color:#F97583">!==</span><span style="color:#9ECBFF"> 'undefined'</span><span style="color:#F97583"> ||</span><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#F97583"> typeof</span><span style="color:#E1E4E8"> window.TypesenseInstantSearchAdapter </span><span style="color:#F97583">!==</span><span style="color:#9ECBFF"> 'undefined'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'📦 依赖库检查 ('</span><span style="color:#F97583"> +</span><span style="color:#E1E4E8"> (initRetryCount </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">+</span><span style="color:#9ECBFF"> '/'</span><span style="color:#F97583"> +</span><span style="color:#79B8FF"> MAX_INIT_RETRIES</span><span style="color:#F97583"> +</span><span style="color:#9ECBFF"> '):'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">' instantsearch.js:'</span><span style="color:#E1E4E8">, instantsearchLoaded </span><span style="color:#F97583">?</span><span style="color:#9ECBFF"> '✅ 已加载'</span><span style="color:#F97583"> :</span><span style="color:#9ECBFF"> '❌ 未加载'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">' TypesenseAdapter:'</span><span style="color:#E1E4E8">, adapterLoaded </span><span style="color:#F97583">?</span><span style="color:#9ECBFF"> '✅ 已加载'</span><span style="color:#F97583"> :</span><span style="color:#9ECBFF"> '❌ 未加载'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">instantsearchLoaded </span><span style="color:#F97583">||</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">adapterLoaded) {</span></span>
<span class="line"><span style="color:#E1E4E8"> initRetryCount</span><span style="color:#F97583">++</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (initRetryCount </span><span style="color:#F97583">>=</span><span style="color:#79B8FF"> MAX_INIT_RETRIES</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">''</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'❌ Typesense 依赖库加载失败!'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">''</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'🔧 请检查 _config.butterfly.yml 配置:'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">''</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'inject:'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">' bottom: # ⚠️ 使用 bottom 而不是 head'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">' - &#x3C;script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.56.0">&#x3C;/script>'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">' - &#x3C;script src="https://cdn.jsdelivr.net/npm/typesense-instantsearch-adapter@2.7.0/dist/typesense-instantsearch-adapter.min.js">&#x3C;/script>'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">' - &#x3C;script src="/js/typesense-search-fixed.js">&#x3C;/script>'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">''</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'💡 或在控制台手动检查:'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">' typeof instantsearch'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">' typeof TypesenseInstantSearchAdapter'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">''</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#B392F0"> showErrorMessage</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">warn</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'⏳ 100ms 后重试...'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#B392F0"> setTimeout</span><span style="color:#E1E4E8">(initTypesense, </span><span style="color:#79B8FF">100</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#E1E4E8"> initRetryCount </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'🚀 开始初始化 Typesense...'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> try</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> typesenseAdapter</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> TypesenseInstantSearchAdapter</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8"> server: {</span></span>
<span class="line"><span style="color:#E1E4E8"> apiKey: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.apiKey,</span></span>
<span class="line"><span style="color:#E1E4E8"> nodes: [{</span></span>
<span class="line"><span style="color:#E1E4E8"> host: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.server.host,</span></span>
<span class="line"><span style="color:#E1E4E8"> port: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.server.port,</span></span>
<span class="line"><span style="color:#E1E4E8"> protocol: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.server.protocol</span></span>
<span class="line"><span style="color:#E1E4E8"> }],</span></span>
<span class="line"><span style="color:#E1E4E8"> cacheSearchResultsForSeconds: </span><span style="color:#79B8FF">120</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8"> additionalSearchParameters: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.searchParams</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> searchInstance </span><span style="color:#F97583">=</span><span style="color:#B392F0"> instantsearch</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8"> searchClient: typesenseAdapter.searchClient,</span></span>
<span class="line"><span style="color:#E1E4E8"> indexName: </span><span style="color:#79B8FF">CONFIG</span><span style="color:#E1E4E8">.indexName,</span></span>
<span class="line"><span style="color:#E1E4E8"> routing: </span><span style="color:#79B8FF">false</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> searchInstance.</span><span style="color:#B392F0">addWidgets</span><span style="color:#E1E4E8">([</span></span>
<span class="line"><span style="color:#E1E4E8"> instantsearch.widgets.</span><span style="color:#B392F0">searchBox</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8"> container: </span><span style="color:#9ECBFF">'#searchbox'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> placeholder: </span><span style="color:#9ECBFF">'输入关键词寻找故事...'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> autofocus: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> showReset: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> showSubmit: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> showLoadingIndicator: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#E1E4E8"> }),</span></span>
<span class="line"><span style="color:#E1E4E8"> instantsearch.widgets.</span><span style="color:#B392F0">stats</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8"> container: </span><span style="color:#9ECBFF">'#stats'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> templates: {</span></span>
<span class="line"><span style="color:#B392F0"> text</span><span style="color:#E1E4E8">: </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">data</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">data.query) </span><span style="color:#F97583">return</span><span style="color:#9ECBFF"> ''</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#9ECBFF"> '找到 &#x3C;strong>'</span><span style="color:#F97583"> +</span><span style="color:#E1E4E8"> data.nbHits </span><span style="color:#F97583">+</span><span style="color:#9ECBFF"> '&#x3C;/strong> 条结果 ('</span><span style="color:#F97583"> +</span><span style="color:#E1E4E8"> data.processingTimeMS </span><span style="color:#F97583">+</span><span style="color:#9ECBFF"> 'ms)'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> }),</span></span>
<span class="line"><span style="color:#E1E4E8"> instantsearch.widgets.</span><span style="color:#B392F0">hits</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8"> container: </span><span style="color:#9ECBFF">'#hits'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> templates: {</span></span>
<span class="line"><span style="color:#B392F0"> empty</span><span style="color:#E1E4E8">: </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">results</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#9ECBFF"> '&#x3C;div class="ts-empty">'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div>&#x3C;i class="fas fa-search">&#x3C;/i>&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div>找不到与 "&#x3C;strong>'</span><span style="color:#F97583"> +</span><span style="color:#E1E4E8"> results.query </span><span style="color:#F97583">+</span><span style="color:#9ECBFF"> '&#x3C;/strong>" 相关的内容&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div style="margin-top: 10px;">试试其他关键词吧 (´·ω·`)&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;/div>'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#B392F0"> item</span><span style="color:#E1E4E8">: </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">hit</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#6A737D"> // 使用 _highlightResult 获取高亮文本</span></span>
<span class="line"><span style="color:#F97583"> var</span><span style="color:#E1E4E8"> titleHighlight </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> hit._highlightResult </span><span style="color:#F97583">&#x26;&#x26;</span><span style="color:#E1E4E8"> hit._highlightResult.title </span></span>
<span class="line"><span style="color:#F97583"> ?</span><span style="color:#E1E4E8"> hit._highlightResult.title.value </span></span>
<span class="line"><span style="color:#F97583"> :</span><span style="color:#E1E4E8"> (hit.title </span><span style="color:#F97583">||</span><span style="color:#9ECBFF"> ''</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#F97583"> var</span><span style="color:#E1E4E8"> contentHighlight </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> hit._highlightResult </span><span style="color:#F97583">&#x26;&#x26;</span><span style="color:#E1E4E8"> hit._highlightResult.content </span></span>
<span class="line"><span style="color:#F97583"> ?</span><span style="color:#E1E4E8"> hit._highlightResult.content.value </span></span>
<span class="line"><span style="color:#F97583"> :</span><span style="color:#E1E4E8"> (hit.content </span><span style="color:#F97583">||</span><span style="color:#9ECBFF"> ''</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#6A737D"> // 截取内容长度</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (contentHighlight.</span><span style="color:#79B8FF">length</span><span style="color:#F97583"> ></span><span style="color:#79B8FF"> 200</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8"> contentHighlight </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> contentHighlight.</span><span style="color:#B392F0">substring</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">200</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">+</span><span style="color:#9ECBFF"> '...'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#F97583"> return</span><span style="color:#9ECBFF"> '&#x3C;a href="'</span><span style="color:#F97583"> +</span><span style="color:#E1E4E8"> hit.url </span><span style="color:#F97583">+</span><span style="color:#9ECBFF"> '" class="ts-result-item">'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div class="ts-result-title">'</span><span style="color:#F97583"> +</span><span style="color:#E1E4E8"> titleHighlight </span><span style="color:#F97583">+</span><span style="color:#9ECBFF"> '&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;div class="ts-result-content">'</span><span style="color:#F97583"> +</span><span style="color:#E1E4E8"> contentHighlight </span><span style="color:#F97583">+</span><span style="color:#9ECBFF"> '&#x3C;/div>'</span><span style="color:#F97583"> +</span></span>
<span class="line"><span style="color:#9ECBFF"> '&#x3C;/a>'</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> }),</span></span>
<span class="line"><span style="color:#E1E4E8"> instantsearch.widgets.</span><span style="color:#B392F0">pagination</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8"> container: </span><span style="color:#9ECBFF">'#pagination'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> padding: </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> showFirst: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8"> showLast: </span><span style="color:#79B8FF">false</span></span>
<span class="line"><span style="color:#E1E4E8"> })</span></span>
<span class="line"><span style="color:#E1E4E8"> ]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> searchInstance.</span><span style="color:#B392F0">start</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> isInitialized </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> true</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8"> </span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'✅ Typesense 初始化成功!'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> searchInstance.</span><span style="color:#B392F0">on</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'render'</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (isSearchOpen) {</span></span>
<span class="line"><span style="color:#F97583"> const</span><span style="color:#79B8FF"> input</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">querySelector</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'.ais-SearchBox-input'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (input </span><span style="color:#F97583">&#x26;&#x26;</span><span style="color:#E1E4E8"> document.activeElement </span><span style="color:#F97583">!==</span><span style="color:#E1E4E8"> input) {</span></span>
<span class="line"><span style="color:#E1E4E8"> input.</span><span style="color:#B392F0">focus</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> });</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8"> } </span><span style="color:#F97583">catch</span><span style="color:#E1E4E8"> (error) {</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'❌ 初始化失败:'</span><span style="color:#E1E4E8">, error);</span></span>
<span class="line"><span style="color:#B392F0"> showErrorMessage</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // 初始化</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#F97583"> function</span><span style="color:#B392F0"> init</span><span style="color:#E1E4E8">() {</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'🔍 Typesense 搜索已准备就绪'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'💡 快捷键: Ctrl/Cmd + K'</span><span style="color:#E1E4E8">);</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (document.readyState </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> 'loading'</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8"> document.</span><span style="color:#B392F0">addEventListener</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'DOMContentLoaded'</span><span style="color:#E1E4E8">, init);</span></span>
<span class="line"><span style="color:#E1E4E8"> } </span><span style="color:#F97583">else</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#B392F0"> init</span><span style="color:#E1E4E8">();</span></span>
<span class="line"><span style="color:#E1E4E8"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#6A737D"> // 全局接口</span></span>
<span class="line"><span style="color:#6A737D"> // ============================================================================</span></span>
<span class="line"><span style="color:#E1E4E8"> window.TypesenseSearch </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8"> open: openSearch,</span></span>
<span class="line"><span style="color:#E1E4E8"> close: closeSearch,</span></span>
<span class="line"><span style="color:#B392F0"> isOpen</span><span style="color:#E1E4E8">: </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">() { </span><span style="color:#F97583">return</span><span style="color:#E1E4E8"> isSearchOpen; },</span></span>
<span class="line"><span style="color:#B392F0"> getInstance</span><span style="color:#E1E4E8">: </span><span style="color:#F97583">function</span><span style="color:#E1E4E8">() { </span><span style="color:#F97583">return</span><span style="color:#E1E4E8"> searchInstance; }</span></span>
<span class="line"><span style="color:#E1E4E8"> };</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">})();</span></span></code></pre>
<h1 id="注意">注意</h1>
<div class="butterfly-note butterfly-note--red butterfly-note--simple"><i class="fa-solid fa-circle-info" aria-hidden="true"></i><div class="butterfly-note__content"><p>indexName: “blogs” 和 collectionName: posts 要一致!!!</p></div></div> </div> <section class="post-copyright-card" aria-label="文章版权信息"> <div class="post-copyright-mark" aria-hidden="true"> <span>CC</span> </div> <div class="post-copyright-main"> <h2>添加typesense搜索</h2> <a class="post-copyright-link" href="/posts/f287c563/">/posts/f287c563/</a> <dl class="post-copyright-meta"> <div> <dt>作者</dt> <dd>biss</dd> </div> <div> <dt>发布于</dt> <dd><time datetime="2026-02-05T13:14:16.000Z">2026/02/05</time></dd> </div> <div> <dt>许可协议</dt> <dd><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noreferrer">CC BY-NC-SA 4.0</a></dd> </div> </dl> </div> <div class="post-copyright-footer"> <div class="post-copyright-actions" aria-label="文章操作"> <button class="post-copyright-action is-primary post-share-button" type="button" data-share-title="添加typesense搜索" data-share-url="https://blog.biss.click/posts/f287c563/" title="分享文章"> <svg aria-hidden="true" viewBox="0 0 24 24"> <path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"></path> <path d="M12 16V4"></path> <path d="m7 9 5-5 5 5"></path> </svg> <span>分享</span> </button> <a class="post-copyright-action" href="/rss.xml" title="订阅 RSS"> <svg aria-hidden="true" viewBox="0 0 24 24"> <path d="M5 5a14 14 0 0 1 14 14"></path> <path d="M5 11a8 8 0 0 1 8 8"></path> <path d="M6 18h.01"></path> </svg> <span>订阅</span> </a> <a class="post-copyright-action" href="/about/" title="打赏作者"> <svg aria-hidden="true" viewBox="0 0 24 24"> <path d="M7 8h10"></path> <path d="M8 8V5h8v3"></path> <path d="M6 8h12l-1.5 11h-9L6 8Z"></path> <path d="M10 12h4"></path> </svg> <span>打赏</span> </a> <a class="post-copyright-icon" href="https://twitter.com/intent/tweet?url=https%3A%2F%2Fblog.biss.click%2Fposts%2Ff287c563%2F&amp;text=%E6%B7%BB%E5%8A%A0typesense%E6%90%9C%E7%B4%A2" target="_blank" rel="noreferrer" title="分享到 X"> <span aria-hidden="true">x</span> </a> <a class="post-copyright-icon" href="https://service.weibo.com/share/share.php?url=https%3A%2F%2Fblog.biss.click%2Fposts%2Ff287c563%2F&amp;title=%E6%B7%BB%E5%8A%A0typesense%E6%90%9C%E7%B4%A2" target="_blank" rel="noreferrer" title="分享到微博"> <span aria-hidden="true"></span> </a> </div> </div> </section> <script>
(() => {
document.querySelectorAll('.post-share-button').forEach((button) => {
button.addEventListener('click', async () => {
const title = button.dataset.shareTitle || document.title;
const url = button.dataset.shareUrl || window.location.href;
const label = button.querySelector('span');
const originalText = label?.textContent || '分享';
try {
if (navigator.share) {
await navigator.share({ title, url });
return;
}
await navigator.clipboard.writeText(url);
if (label) label.textContent = '已复制';
} catch {
if (label) label.textContent = '复制失败';
} finally {
window.setTimeout(() => {
if (label) label.textContent = originalText;
}, 1800);
}
});
});
})();
</script> <section id="post-comment" class="twikoo-comments" aria-label="Comments" data-env-id="https://comment.biss.click" data-region data-path="/posts/f287c563/" data-script="https://cdn.jsdelivr.net/npm/twikoo@1.7.7/dist/twikoo.min.js"> <div class="twikoo-comments-head"> <span class="twikoo-comments-icon" aria-hidden="true"> <svg viewBox="0 0 24 24"> <path d="M21 12a8 8 0 0 1-8 8H7l-4 3v-5a8 8 0 1 1 18-6Z"></path> </svg> </span> <div> <h2>Comments</h2> <p>添加typesense搜索</p> </div> </div> <div id="twikoo-wrap" class="twikoo-wrap"></div> </section> <script>
(() => {
const root = document.getElementById('post-comment');
const wrap = document.getElementById('twikoo-wrap');
if (!root || !wrap || root.dataset.twikooLoaded === 'true') return;
const envId = root.dataset.envId;
const path = root.dataset.path || window.location.pathname;
const region = root.dataset.region || undefined;
if (!envId) return;
root.dataset.twikooLoaded = 'true';
const initTwikoo = () => {
if (!window.twikoo || typeof window.twikoo.init !== 'function') return;
window.twikoo.init({
el: wrap,
envId,
region,
path,
});
};
if (window.twikoo) {
initTwikoo();
return;
}
const script = document.createElement('script');
script.src = root.dataset.script;
script.defer = true;
script.onload = initTwikoo;
script.onerror = () => {
root.dataset.twikooLoaded = 'false';
};
document.head.appendChild(script);
})();
</script> </article> <aside class="article-sidebar" aria-label="文章侧边栏"> <section class="sidebar-card article-author-card"> <img class="profile-avatar" src="https://free.picui.cn/free/2025/08/10/689845496a283.png" alt="biss" loading="lazy"> <div class="profile-name">biss</div> <div class="article-author-meta"> <span>2026/02/05</span> <a href="/categories/tech/">技术</a> </div> </section> <div class="article-sticky-sidebar"> <section class="sidebar-card toc-card"> <h2> <span aria-hidden="true"></span>
文章目录
<em>5</em> </h2> <nav class="toc-list toc-tree" aria-label="文章目录" data-toc-tree> <div class="toc-group is-open" data-toc-group data-heading-id="安装typesense"> <a class="toc-link toc-link-main" href="#安装typesense" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>安装typesense</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="添加数据集"> <a class="toc-link toc-link-main" href="#添加数据集" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>添加数据集</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="创建只读key"> <a class="toc-link toc-link-main" href="#创建只读key" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>创建只读key</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="博客添加搜索"> <a class="toc-link toc-link-main" href="#博客添加搜索" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>博客添加搜索</span> </a> </div><div class="toc-group" data-toc-group data-heading-id="注意"> <a class="toc-link toc-link-main" href="#注意" data-toc-link data-toc-top> <span class="toc-marker" aria-hidden="true"></span> <span>注意</span> </a> </div> </nav> </section> <section class="sidebar-card same-category-card"> <h2> <span aria-hidden="true"></span>
同类文章
<em>8</em> </h2> <div class="same-category-list"> <a href="/posts/8f3b9d21/"> <strong>网站迁移日志</strong> <time datetime="2026-06-19T16:45:00.000Z">2026/06/20</time> </a><a href="/posts/4c91a8f2/"> <strong>用 Umami 分享链接给网站信息卡提供统计数据</strong> <time datetime="2026-06-19T08:58:00.000Z">2026/06/19</time> </a><a href="/posts/3f8de22/"> <strong>GPT Image2 初试</strong> <time datetime="2026-04-25T15:00:00.000Z">2026/04/25</time> </a><a href="/posts/8712baaa/"> <strong>电子价签玩法</strong> <time datetime="2026-04-19T13:46:25.000Z">2026/04/19</time> </a><a href="/posts/9b2ef00a/"> <strong>OpenGL- Cohen-Sutherland 编码裁剪算法</strong> <time datetime="2026-04-15T19:34:23.000Z">2026/04/16</time> </a><a href="/posts/7207243b/"> <strong>OpenGL-直线的扫描转换</strong> <time datetime="2026-04-11T19:01:02.000Z">2026/04/12</time> </a><a href="/posts/437a5198/"> <strong>OpenGL-基础程序</strong> <time datetime="2026-04-11T18:20:34.000Z">2026/04/12</time> </a><a href="/posts/c4477b0c/"> <strong>配置OpenGL环境</strong> <time datetime="2026-04-02T04:52:16.000Z">2026/04/02</time> </a> </div> </section> <section class="sidebar-card site-info-card" data-site-info-card data-stats-endpoint="https://blogumami.biss.click"> <h2><i class="fa-solid fa-chart-line" aria-hidden="true"></i> 网站信息</h2> <dl class="site-info-list"> <div> <dt>文章数目</dt> <dd>44</dd> </div><div> <dt>本站总字数</dt> <dd>36,128</dd> </div><div> <dt>本站访客数</dt> <dd data-stat-key="visitors">--</dd> </div><div> <dt>本站访问量</dt> <dd data-stat-key="pageviews">--</dd> </div><div> <dt>最后更新时间</dt> <dd>2026/06/20</dd> </div> </dl> </section> <script>
(() => {
const cacheDuration = 10 * 60 * 1000;
const formatter = new Intl.NumberFormat('en-US');
const cards = [...document.querySelectorAll('[data-site-info-card]')];
const endpoint = cards.find((card) => card.dataset.statsEndpoint)?.dataset.statsEndpoint;
if (!endpoint) return;
const cacheKey = `site-info-stats:${endpoint}`;
const updateCards = (stats) => {
const visitors = Number(stats?.visitors);
const pageviews = Number(stats?.pageviews);
cards.forEach((card) => {
const visitorsEl = card.querySelector('[data-stat-key="visitors"]');
const pageviewsEl = card.querySelector('[data-stat-key="pageviews"]');
if (visitorsEl && Number.isFinite(visitors)) visitorsEl.textContent = formatter.format(visitors);
if (pageviewsEl && Number.isFinite(pageviews)) pageviewsEl.textContent = formatter.format(pageviews);
});
};
try {
const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null');
if (cached?.time && Date.now() - cached.time < cacheDuration) {
updateCards(cached.data);
return;
}
} catch {
localStorage.removeItem(cacheKey);
}
fetch(endpoint, { headers: { Accept: 'application/json' } })
.then((response) => {
if (!response.ok) throw new Error(`Stats request failed: ${response.status}`);
return response.json();
})
.then((data) => {
localStorage.setItem(cacheKey, JSON.stringify({ time: Date.now(), data }));
updateCards(data);
})
.catch(() => {
});
})();
</script> </div> </aside> </section> </main> <footer class="site-footer" aria-label="站点页脚"> <div class="footer-inner"> <section class="footer-top" aria-label="站点信息"> <a class="footer-brand" href="/" aria-label="Bi's Blog 首页"> <img src="/images/Bi.ico" alt="" loading="lazy"> <span>Bi&#39;s Blog</span> </a> <p class="footer-slogan">好奇心转化为代码的空间。</p> </section> <section class="footer-contact" aria-label="联系方式"> <p>联系方式</p> <div class="footer-socials"> <a href="mailto:bishsh2006@gmail.com" aria-label="发送邮件" title="发送邮件"> <i class="fa-solid fa-envelope" aria-hidden="true"></i> </a><a href="/rss.xml" aria-label="订阅 RSS" title="订阅 RSS"> <i class="fa-solid fa-rss" aria-hidden="true"></i> </a><a href="https://github.com/bishshi" target="_blank" rel="noreferrer" aria-label="GitHub" title="GitHub"> <i class="fa-brands fa-github" aria-hidden="true"></i> </a> </div> <nav class="footer-contact-links" aria-label="页脚链接"> <a href="/archives/">发展历程</a> <span aria-hidden="true">|</span><a href="/privacy/">隐私政策</a> </nav> </section> <section class="footer-bottom" aria-label="版权信息"> <div> <p>© 2025 - 2026 Bi&#39;s Blog</p> <p>blog.biss.click | 内容采用 CC BY-NC-SA 4.0 协议</p> </div> <div class="footer-meta"> <p>本站已运行:<span id="site-runtime">计算中...</span></p> <p> <a href="https://fastly.com/" target="_blank" rel="noreferrer">Fastly</a> <span aria-hidden="true">|</span><a href="https://astro.build/" target="_blank" rel="noreferrer">ASTRO</a> </p> </div> </section> </div> </footer> <aside class="hitokoto-card is-collapsed" aria-label="一言" data-hitokoto-card data-astro-cid-4cgfuthx> <div class="hitokoto-card__head" data-astro-cid-4cgfuthx> <button class="hitokoto-card__toggle" type="button" aria-label="展开一言" aria-expanded="false" title="一言" data-hitokoto-toggle data-astro-cid-4cgfuthx> <i class="fa-solid fa-quote-left" aria-hidden="true" data-astro-cid-4cgfuthx></i> <span data-astro-cid-4cgfuthx>一言</span> </button> <div class="hitokoto-card__actions" data-astro-cid-4cgfuthx> <button class="hitokoto-card__refresh" type="button" aria-label="换一句" title="换一句" data-hitokoto-refresh data-astro-cid-4cgfuthx> <i class="fa-solid fa-rotate" aria-hidden="true" data-astro-cid-4cgfuthx></i> </button> <button class="hitokoto-card__collapse" type="button" aria-label="收起一言" title="收起" data-hitokoto-collapse data-astro-cid-4cgfuthx> <i class="fa-solid fa-chevron-down" aria-hidden="true" data-astro-cid-4cgfuthx></i> </button> </div> </div> <a class="hitokoto-card__text" href="https://hitokoto.cn/" target="_blank" rel="noreferrer" data-hitokoto-text data-astro-cid-4cgfuthx>
获取中...
</a> <p class="hitokoto-card__from" data-hitokoto-from data-astro-cid-4cgfuthx>来自 Hitokoto</p> </aside> <script type="module">(()=>{const o=document.querySelector("[data-hitokoto-card]"),c=o?.querySelector("[data-hitokoto-toggle]"),s=o?.querySelector("[data-hitokoto-text]"),l=o?.querySelector("[data-hitokoto-from]"),g=o?.querySelector("[data-hitokoto-refresh]"),k=o?.querySelector("[data-hitokoto-collapse]");if(!(o instanceof HTMLElement)||!(c instanceof HTMLButtonElement)||!(s instanceof HTMLAnchorElement)||!(l instanceof HTMLElement))return;const y="https://yiyan.biss.click/api?encode=json",u="hitokoto-card-cache",d="hitokoto-card-expanded",x=360*60*1e3,f={sentence:"生活明朗,万物可爱。",source:"本地缓存",url:"https://hitokoto.cn/"},S=["/","/index.html"].includes(window.location.pathname),w=window.matchMedia("(max-width: 720px)");function E(){try{const t=localStorage.getItem(d);if(t==="true")return!0;if(t==="false")return!1}catch{}return null}function L(t){try{localStorage.setItem(d,String(t))}catch{}}function r(t,{persist:e=!1}={}){o.classList.toggle("is-collapsed",!t),c.setAttribute("aria-expanded",String(t)),c.setAttribute("aria-label",t?"收起一言":"展开一言"),e&&L(t)}function h(){try{const t=localStorage.getItem(u);if(!t)return null;const e=JSON.parse(t);return!e||typeof e.expiresAt!="number"||e.expiresAt<=Date.now()?null:e.data}catch{return null}}function H(t){try{localStorage.setItem(u,JSON.stringify({data:t,expiresAt:Date.now()+x}))}catch{}}function i(t){s.textContent=t.sentence,s.href=t.url||"https://hitokoto.cn/",l.textContent=t.source?`出自 ${t.source}`:"来自 Hitokoto"}async function p({force:t=!1}={}){if(!t){const e=h();if(e){i(e);return}}o.classList.add("is-loading"),s.textContent="获取中...";try{const e=await fetch(y);if(!e.ok)throw new Error(`Hitokoto request failed: ${e.status}`);const n=await e.json(),a=n.hitokoto||n.content||n.sentence||n.text,q=typeof a=="string"&&a.trim()?a.trim():f.sentence,A=typeof n.from=="string"&&n.from.trim()?n.from.trim():"Hitokoto",M=n.uuid?`https://hitokoto.cn/?uuid=${encodeURIComponent(n.uuid)}`:"https://hitokoto.cn/",m={sentence:q,source:A,url:M};H(m),i(m)}catch(e){console.error(e),i(h()||f)}finally{o.classList.remove("is-loading")}}const b=E();r(b??(S&&!w.matches)),c.addEventListener("click",()=>{r(o.classList.contains("is-collapsed"),{persist:!0})}),g?.addEventListener("click",t=>{t.stopPropagation(),r(!0,{persist:!0}),p({force:!0})}),k?.addEventListener("click",t=>{t.stopPropagation(),r(!1,{persist:!0})}),o.addEventListener("click",t=>{o.classList.contains("is-collapsed")&&(t.target instanceof Element&&t.target.closest("a, button")||r(!0,{persist:!0}))}),p()})();</script> <div class="custom-context-menu" data-context-menu role="menu" aria-hidden="true"> <div class="context-menu-nav" aria-label="页面导航"> <button type="button" data-context-action="back" aria-label="后退上一页" title="后退上一页"> <i class="fa-solid fa-arrow-left" aria-hidden="true"></i> </button> <button type="button" data-context-action="forward" aria-label="前进下一页" title="前进下一页"> <i class="fa-solid fa-arrow-right" aria-hidden="true"></i> </button> <button type="button" data-context-action="reload" aria-label="刷新页面" title="刷新页面"> <i class="fa-solid fa-rotate-right" aria-hidden="true"></i> </button> <a href="/" aria-label="返回首页" title="返回首页"> <i class="fa-solid fa-house" aria-hidden="true"></i> </a> </div> <div class="context-menu-group" data-selection-group hidden> <button type="button" data-context-action="copy-selection" data-selection-item> <i class="fa-regular fa-copy" aria-hidden="true"></i> <span>复制选中文字</span> </button> <button type="button" data-context-action="search-selection" data-selection-item> <i class="fa-brands fa-microsoft" aria-hidden="true"></i> <span>用 Bing 搜索</span> </button> <button type="button" data-context-action="quote-selection" data-selection-item data-comments-item> <i class="fa-regular fa-comment-dots" aria-hidden="true"></i> <span>引用到评论区</span> </button> </div> <div class="context-menu-group" data-image-group hidden> <button type="button" data-context-action="copy-image"> <i class="fa-regular fa-image" aria-hidden="true"></i> <span>复制图片</span> </button> <button type="button" data-context-action="open-image"> <i class="fa-solid fa-up-right-from-square" aria-hidden="true"></i> <span>新页面打开图片</span> </button> <button type="button" data-context-action="copy-image-url"> <i class="fa-solid fa-link" aria-hidden="true"></i> <span>复制图片地址</span> </button> </div> <div class="context-menu-group"> <a href="/archives/"> <i class="fa-solid fa-box-archive" aria-hidden="true"></i> <span>时间线</span> </a> <a href="/categories/"> <i class="fa-solid fa-folder" aria-hidden="true"></i> <span>分类</span> </a> <a href="/tags/"> <i class="fa-solid fa-hashtag" aria-hidden="true"></i> <span>标签</span> </a> <button type="button" data-context-action="random-post"> <i class="fa-solid fa-shuffle" aria-hidden="true"></i> <span>&#x968F;&#x673A;&#x6587;&#x7AE0;</span> </button> <button type="button" data-context-action="comments" data-comments-item> <i class="fa-regular fa-comment-dots" aria-hidden="true"></i> <span>跳转评论区</span> </button> <button type="button" data-context-action="print"> <i class="fa-solid fa-print" aria-hidden="true"></i> <span>打印页面</span> </button> <a href="/cc/"> <i class="fa-solid fa-circle-info" aria-hidden="true"></i> <span>网站版权</span> </a> </div> </div> <div class="context-menu-native-hint" data-context-native-hint role="note" aria-label="Ctrl 右键提示"> <kbd>Ctrl</kbd> + &#x53F3;&#x952E;&#xFF1A;&#x6D4F;&#x89C8;&#x5668;&#x539F;&#x751F;&#x83DC;&#x5355;
</div> <script>
(() => {
const menu = document.querySelector('[data-context-menu]');
if (!menu) return;
const nativeHint = document.querySelector('[data-context-native-hint]');
const selectionItems = [...menu.querySelectorAll('[data-selection-item]')];
const selectionGroup = menu.querySelector('[data-selection-group]');
const commentsItems = [...menu.querySelectorAll('[data-comments-item]')];
const imageGroup = menu.querySelector('[data-image-group]');
const focusableSelector = 'a[href], button:not([disabled])';
let lastSelectionText = '';
let lastImage = null;
let nativeHintTimer = 0;
const isNativeContextTarget = (target) =>
target instanceof Element &&
Boolean(target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
const getCommentsTarget = () =>
document.querySelector('.twikoo-comments, #twikoo-wrap, #twikoo, .tk-comments-container');
const getSelectionText = () => String(window.getSelection?.() || '').trim();
const getImageTarget = (target) => {
if (!(target instanceof Element)) return null;
const image = target.closest('img');
return image instanceof HTMLImageElement ? image : null;
};
const getImageUrl = () => {
if (!lastImage) return '';
return lastImage.currentSrc || lastImage.src || lastImage.getAttribute('src') || '';
};
const copyText = async (text) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.inset = '0 auto auto -9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
};
const setMenuState = () => {
const hasSelection = lastSelectionText.length > 0;
const hasComments = Boolean(getCommentsTarget());
const hasImage = Boolean(getImageUrl());
selectionItems.forEach((item) => {
item.hidden = !hasSelection;
});
if (selectionGroup) selectionGroup.hidden = !hasSelection;
commentsItems.forEach((item) => {
item.hidden = !hasComments || (item.hasAttribute('data-selection-item') && !hasSelection);
});
if (imageGroup) imageGroup.hidden = !hasImage;
};
const closeMenu = () => {
menu.classList.remove('is-open');
menu.setAttribute('aria-hidden', 'true');
};
const openMenu = (x, y) => {
setMenuState();
menu.classList.add('is-open');
menu.setAttribute('aria-hidden', 'false');
const rect = menu.getBoundingClientRect();
const gap = 10;
const left = Math.min(Math.max(gap, x), window.innerWidth - rect.width - gap);
const top = Math.min(Math.max(gap, y), window.innerHeight - rect.height - gap);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
};
const showNativeHint = () => {
if (!nativeHint) return;
window.clearTimeout(nativeHintTimer);
nativeHint.classList.add('is-visible');
nativeHintTimer = window.setTimeout(() => {
nativeHint.classList.remove('is-visible');
}, 2600);
};
const jumpToComments = () => {
const target = getCommentsTarget();
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const quoteToComments = () => {
jumpToComments();
window.setTimeout(() => {
const textarea = document.querySelector('#twikoo textarea, .twikoo-comments textarea, .tk-input textarea');
if (!(textarea instanceof HTMLTextAreaElement) || !lastSelectionText) return;
const quote = lastSelectionText
.split(/\r?\n/)
.map((line) => `> ${line}`)
.join('\n');
const current = textarea.value.trim();
textarea.value = current ? `${current}\n\n${quote}\n` : `${quote}\n`;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
}, 500);
};
const searchSelection = () => {
if (!lastSelectionText) return;
const url = `https://www.bing.com/search?q=${encodeURIComponent(lastSelectionText)}`;
window.open(url, '_blank', 'noopener,noreferrer');
};
const copyImage = async () => {
const imageUrl = getImageUrl();
if (!imageUrl) return;
try {
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
throw new Error('Image clipboard is not available.');
}
const response = await fetch(imageUrl, { mode: 'cors' });
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({ [blob.type || 'image/png']: blob }),
]);
} catch {
await copyText(new URL(imageUrl, window.location.href).toString());
}
};
const openImage = () => {
const imageUrl = getImageUrl();
if (!imageUrl) return;
window.open(new URL(imageUrl, window.location.href).toString(), '_blank', 'noopener,noreferrer');
};
document.addEventListener('contextmenu', (event) => {
if (event.ctrlKey) {
closeMenu();
nativeHint?.classList.remove('is-visible');
return;
}
if (isNativeContextTarget(event.target)) return;
lastSelectionText = getSelectionText();
lastImage = getImageTarget(event.target);
event.preventDefault();
openMenu(event.clientX, event.clientY);
showNativeHint();
});
document.addEventListener('click', (event) => {
const actionTarget = event.target instanceof Element ? event.target.closest('[data-context-action]') : null;
if (!actionTarget || !menu.contains(actionTarget)) {
closeMenu();
return;
}
const action = actionTarget.dataset.contextAction;
closeMenu();
if (action === 'back') history.back();
if (action === 'forward') history.forward();
if (action === 'reload') location.reload();
if (action === 'copy-selection' && lastSelectionText) copyText(lastSelectionText);
if (action === 'search-selection') searchSelection();
if (action === 'quote-selection') quoteToComments();
if (action === 'copy-image') copyImage();
if (action === 'open-image') openImage();
if (action === 'copy-image-url') copyText(new URL(getImageUrl(), window.location.href).toString());
if (action === 'random-post' && typeof window.randomPost === 'function') window.randomPost(actionTarget);
if (action === 'comments') jumpToComments();
if (action === 'print') window.print();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeMenu();
if (event.key === 'Tab' && menu.classList.contains('is-open')) {
const items = [...menu.querySelectorAll(focusableSelector)].filter((item) => !item.closest('[hidden]'));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
});
window.addEventListener('scroll', closeMenu, { passive: true });
window.addEventListener('resize', closeMenu);
})();
</script> <aside class="pwa-toast" data-pwa-toast hidden role="status" aria-live="polite"> <div class="pwa-toast-icon" aria-hidden="true"><i class="fa-solid fa-download"></i></div> <div class="pwa-toast-copy"> <strong data-pwa-title>PWA 提示</strong> <p data-pwa-message></p> </div> <div class="pwa-toast-actions"> <button class="pwa-toast-primary" type="button" data-pwa-primary>确定</button> <button class="pwa-toast-close" type="button" data-pwa-close aria-label="关闭提示">稍后</button> </div> </aside> <script src="https://cdn.jsdmirror.com/npm/instantsearch.js@4.56.0"></script> <script src="https://cdn.jsdmirror.com/npm/typesense-instantsearch-adapter@2.7.0/dist/typesense-instantsearch-adapter.min.js"></script> <script src="https://cdn.jsdmirror.com/npm/@fancyapps/ui@5.0/dist/fancybox/fancybox.umd.js"></script> <script src="/js/random.js"></script> <script src="/js/search/typesense-search.js"></script> <script src="/js/external-links.js"></script> <script src="/js/pwa.js"></script> <script>
(() => {
const nav = document.querySelector('.nav');
const toggle = document.querySelector('[data-nav-toggle]');
const menu = document.querySelector('[data-nav-menu]');
const backdrop = document.querySelector('[data-nav-backdrop]');
if (!nav || !toggle || !menu) return;
function setMenu(open) {
nav.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute('aria-label', open ? '关闭菜单' : '打开菜单');
menu.setAttribute('aria-hidden', String(!open && window.innerWidth <= 720));
document.body.classList.toggle('has-nav-drawer', open);
}
toggle.addEventListener('click', () => {
setMenu(!nav.classList.contains('is-open'));
});
backdrop?.addEventListener('click', () => setMenu(false));
menu.addEventListener('click', (event) => {
if (event.target instanceof Element && event.target.closest('a')) setMenu(false);
});
document.addEventListener('click', (event) => {
if (!nav.classList.contains('is-open')) return;
if (event.target instanceof Node && nav.contains(event.target)) return;
setMenu(false);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && nav.classList.contains('is-open')) setMenu(false);
});
window.addEventListener('resize', () => {
if (window.innerWidth > 720) setMenu(false);
else menu.setAttribute('aria-hidden', String(!nav.classList.contains('is-open')));
});
menu.setAttribute('aria-hidden', String(window.innerWidth <= 720));
})();
(() => {
const modal = document.querySelector('[data-console-modal]');
const panel = modal?.querySelector('.control-console-panel');
const openButtons = document.querySelectorAll('[data-console-open]');
const closeButtons = modal?.querySelectorAll('[data-console-close]') || [];
if (!modal || !panel || openButtons.length === 0) return;
function setConsole(open) {
modal.classList.toggle('is-open', open);
modal.setAttribute('aria-hidden', String(!open));
document.body.classList.toggle('has-control-console', open);
if (open) panel.focus();
}
openButtons.forEach((button) => {
button.addEventListener('click', () => setConsole(true));
});
closeButtons.forEach((button) => {
button.addEventListener('click', () => setConsole(false));
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal.classList.contains('is-open')) setConsole(false);
});
})();
(() => {
const storageKey = 'site-theme';
const root = document.documentElement;
const button = document.querySelector('[data-theme-toggle]');
const media = window.matchMedia('(prefers-color-scheme: dark)');
const getSystemTheme = () => (media.matches ? 'dark' : 'light');
const getSavedTheme = () => {
try {
const theme = localStorage.getItem(storageKey);
return theme === 'dark' || theme === 'light' ? theme : null;
} catch {
return null;
}
};
const setSavedTheme = (theme) => {
try {
if (theme === getSystemTheme()) localStorage.removeItem(storageKey);
else localStorage.setItem(storageKey, theme);
} catch {
// localStorage can be unavailable in strict privacy modes.
}
};
const getTheme = () => getSavedTheme() || getSystemTheme();
function applyTheme(theme) {
root.dataset.theme = theme;
root.dataset.themeMode = getSavedTheme() ? 'manual' : 'auto';
button?.setAttribute('aria-label', theme === 'dark' ? '切换到日间模式' : '切换到夜间模式');
button?.setAttribute(
'title',
getSavedTheme()
? (theme === 'dark' ? '切换到日间模式' : '切换到夜间模式')
: `跟随系统:${theme === 'dark' ? '夜间' : '日间'}模式`
);
}
applyTheme(getTheme());
button?.addEventListener('click', () => {
const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
setSavedTheme(nextTheme);
applyTheme(nextTheme);
});
const handleSystemThemeChange = () => {
if (!getSavedTheme()) applyTheme(getSystemTheme());
};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', handleSystemThemeChange);
} else if (typeof media.addListener === 'function') {
media.addListener(handleSystemThemeChange);
}
})();
(() => {
const runtime = document.getElementById('site-runtime');
if (!runtime) return;
const start = new Date(window.SITE_CONFIG.site.startTime).getTime();
const pad = (value) => String(value).padStart(2, '0');
function updateRuntime() {
const diff = Math.max(0, Date.now() - start);
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff / 3600000) % 24);
const minutes = Math.floor((diff / 60000) % 60);
const seconds = Math.floor((diff / 1000) % 60);
runtime.textContent = `${days}${pad(hours)}${pad(minutes)}${pad(seconds)} 秒`;
}
updateRuntime();
window.setInterval(updateRuntime, 1000);
})();
</script> </body> </html> <script>(function(){const postSlug = "f287c563";
(() => {
const article = document.querySelector('.post-article');
const progressBar = document.querySelector('[data-reading-progress]');
const resume = document.querySelector('[data-reading-resume]');
const resumePercent = document.querySelector('[data-reading-resume-percent]');
const continueButton = document.querySelector('[data-reading-continue]');
const restartButton = document.querySelector('[data-reading-restart]');
if (!article || !progressBar || !resume) return;
const storageKey = `reading-progress:${postSlug}`;
let saved = null;
let canPersist = true;
let frame = 0;
try {
saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
} catch {
saved = null;
}
const articleMetrics = () => {
const top = article.getBoundingClientRect().top + window.scrollY;
const distance = Math.max(1, article.offsetHeight - window.innerHeight);
return { top, distance };
};
const getProgress = () => {
const { top, distance } = articleMetrics();
return Math.min(1, Math.max(0, (window.scrollY - top) / distance));
};
const persist = (progress) => {
if (!canPersist) return;
try {
if (progress >= 0.97) localStorage.removeItem(storageKey);
else if (progress >= 0.02) localStorage.setItem(storageKey, JSON.stringify({ progress, savedAt: Date.now() }));
} catch {
// Storage can be unavailable in strict privacy modes.
}
};
const update = () => {
frame = 0;
const progress = getProgress();
progressBar.style.transform = `scaleX(${progress})`;
persist(progress);
};
const requestUpdate = () => {
if (!frame) frame = window.requestAnimationFrame(update);
};
const closeResume = () => {
resume.hidden = true;
canPersist = true;
};
const savedIsFresh = saved && Date.now() - Number(saved.savedAt) < 90 * 86400000;
const savedProgress = Number(saved?.progress);
if (savedIsFresh && savedProgress >= 0.05 && savedProgress < 0.97) {
canPersist = false;
if (resumePercent) resumePercent.textContent = `${Math.round(savedProgress * 100)}%`;
window.setTimeout(() => { resume.hidden = false; }, 350);
}
continueButton?.addEventListener('click', () => {
closeResume();
const { top, distance } = articleMetrics();
window.scrollTo({ top: top + distance * savedProgress, behavior: 'smooth' });
});
restartButton?.addEventListener('click', () => {
try { localStorage.removeItem(storageKey); } catch {}
closeResume();
window.scrollTo({ top: Math.max(0, articleMetrics().top - 90), behavior: 'smooth' });
});
update();
window.addEventListener('scroll', requestUpdate, { passive: true });
window.addEventListener('resize', requestUpdate);
window.addEventListener('pagehide', () => persist(getProgress()));
})();
})();</script> <script>
(() => {
document.querySelectorAll('.article-outdated-note[data-freshness-at]').forEach((note) => {
const freshnessAt = new Date(note.dataset.freshnessAt || '');
const hasNewVersion = note.dataset.hasNewVersion === 'true';
if (Number.isNaN(freshnessAt.valueOf())) return;
const threshold = new Date();
threshold.setFullYear(threshold.getFullYear() - 1);
note.hidden = !hasNewVersion && freshnessAt >= threshold;
});
})();
</script> <script>
(() => {
const prose = document.querySelector('.prose[data-toc]');
if (!prose) return;
let toc = [];
try {
toc = JSON.parse(prose.dataset.toc || '[]');
} catch {
toc = [];
}
prose.querySelectorAll('h1, h2, h3').forEach((heading, index) => {
const item = toc[index];
if (item?.id && !heading.id) heading.id = item.id;
});
const tocTree = document.querySelector('[data-toc-tree]');
if (tocTree && toc.length > 0) {
const links = [...tocTree.querySelectorAll('[data-toc-link]')];
const groups = [...tocTree.querySelectorAll('[data-toc-group]')];
const headings = toc
.map((item) => document.getElementById(item.id))
.filter(Boolean);
const setActiveToc = (id) => {
links.forEach((link) => {
const isActive = link.getAttribute('href') === `#${id}`;
link.classList.toggle('is-active', isActive);
if (isActive) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
});
const activeLink = tocTree.querySelector(`[data-toc-link][href="#${CSS.escape(id)}"]`);
const activeGroup = activeLink?.closest('[data-toc-group]');
groups.forEach((group) => group.classList.toggle('is-open', group === activeGroup));
};
const updateToc = () => {
const anchorOffset = 132;
let activeId = headings[0]?.id;
headings.forEach((heading) => {
if (heading.getBoundingClientRect().top <= anchorOffset) activeId = heading.id;
});
if (activeId) setActiveToc(activeId);
};
tocTree.addEventListener('click', (event) => {
const link = event.target.closest?.('[data-toc-link]');
if (!link) return;
const group = link.closest('[data-toc-group]');
groups.forEach((item) => item.classList.toggle('is-open', item === group));
});
updateToc();
window.addEventListener('scroll', updateToc, { passive: true });
window.addEventListener('resize', updateToc);
}
})();
</script> <script>
(() => {
const prose = document.querySelector('.post-article .prose');
if (!prose) return;
const collapsedLineCount = 8;
const copyLabels = {
idle: '复制',
done: '已复制',
error: '复制失败',
};
const languageNames = {
bash: 'Bash',
c: 'C',
cpp: 'C++',
cs: 'C#',
css: 'CSS',
html: 'HTML',
js: 'JavaScript',
json: 'JSON',
jsx: 'JSX',
md: 'Markdown',
powershell: 'PowerShell',
py: 'Python',
shell: 'Shell',
sh: 'Shell',
ts: 'TypeScript',
tsx: 'TSX',
txt: 'Text',
yaml: 'YAML',
yml: 'YAML',
};
function getCodeLanguage(pre, code) {
const rawLanguage =
pre.dataset.language ||
code.dataset.language ||
[...pre.classList, ...code.classList]
.find((className) => className.startsWith('language-'))
?.replace(/^language-/, '') ||
'';
const normalized = rawLanguage.trim().toLowerCase();
return languageNames[normalized] || normalized.toUpperCase() || 'Code';
}
function getCodeText(code, lines) {
if (lines.length > 0) return lines.map((line) => line.textContent || '').join('\n');
return code.textContent || '';
}
prose.querySelectorAll('img').forEach((image) => {
if (image.closest('a, .article-header-cover')) return;
const src = image.currentSrc || image.getAttribute('src');
if (!src) return;
const link = document.createElement('a');
link.className = 'fancybox';
link.href = src;
link.dataset.fancybox = 'article-gallery';
link.dataset.caption = image.getAttribute('alt') || '';
image.parentNode?.insertBefore(link, image);
link.appendChild(image);
});
prose.querySelectorAll('table').forEach((table) => {
if (table.closest('.table-scroll')) return;
const wrapper = document.createElement('div');
wrapper.className = 'table-scroll';
table.parentNode?.insertBefore(wrapper, table);
wrapper.appendChild(table);
});
prose.querySelectorAll('.butterfly-hide-inline').forEach((item) => {
const button = item.querySelector('.butterfly-hide-toggle');
if (!button) return;
button.addEventListener('click', () => {
item.classList.toggle('is-open');
});
});
if (window.Fancybox) {
window.Fancybox.bind('[data-fancybox="article-gallery"]', {
compact: false,
dragToClose: true,
Images: {
zoom: true,
},
});
}
async function copyText(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
prose.querySelectorAll('pre').forEach((pre) => {
if (pre.closest('.code-block')) return;
const code = pre.querySelector('code');
if (!code) return;
const wrapper = document.createElement('div');
wrapper.className = 'code-block';
pre.parentNode?.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
const header = document.createElement('div');
header.className = 'code-block-header';
const language = document.createElement('span');
language.className = 'code-block-language';
language.textContent = getCodeLanguage(pre, code);
const toolbar = document.createElement('div');
toolbar.className = 'code-block-toolbar';
const copyButton = document.createElement('button');
copyButton.className = 'code-block-copy';
copyButton.type = 'button';
copyButton.textContent = copyLabels.idle;
copyButton.setAttribute('aria-label', '复制代码');
const toggleButton = document.createElement('button');
toggleButton.className = 'code-block-toggle';
toggleButton.type = 'button';
toggleButton.textContent = '展开';
toggleButton.setAttribute('aria-expanded', 'false');
const expandCue = document.createElement('button');
expandCue.className = 'code-block-expand-cue';
expandCue.type = 'button';
expandCue.setAttribute('aria-label', 'Expand code block');
expandCue.setAttribute('aria-expanded', 'false');
expandCue.innerHTML = '<span aria-hidden="true"></span>';
toolbar.append(copyButton, toggleButton);
header.append(language, toolbar);
wrapper.appendChild(header);
wrapper.appendChild(expandCue);
const codeLines = [...code.querySelectorAll(':scope > .line')];
if (codeLines.length > 0) {
wrapper.classList.add('has-line-numbers');
wrapper.style.setProperty('--line-number-width', `${String(codeLines.length).length + 1}ch`);
codeLines.forEach((line, index) => {
line.dataset.line = String(index + 1);
});
}
const lineCount = codeLines.length || (code.textContent || '').split(/\r?\n/).length;
if (lineCount > collapsedLineCount) wrapper.classList.add('is-collapsible');
copyButton.addEventListener('click', async () => {
try {
await copyText(getCodeText(code, codeLines));
copyButton.textContent = copyLabels.done;
window.setTimeout(() => {
copyButton.textContent = copyLabels.idle;
}, 1400);
} catch {
copyButton.textContent = copyLabels.error;
window.setTimeout(() => {
copyButton.textContent = copyLabels.idle;
}, 1600);
}
});
function toggleExpanded() {
const isExpanded = wrapper.classList.toggle('is-expanded');
toggleButton.textContent = isExpanded ? '收起' : '展开';
toggleButton.setAttribute('aria-expanded', String(isExpanded));
expandCue.setAttribute('aria-expanded', String(isExpanded));
}
toggleButton.addEventListener('click', toggleExpanded);
expandCue.addEventListener('click', toggleExpanded);
});
})();
</script>