搜 | 终于安排上站内搜索功能啦!

站内搜索功能

Hugo官方提供了多种站内搜索的解决方案:https://gohugo.io/tools/search/,不过好像由于文档比较晦涩而且很久没人维护的原因,上手起来都不是很方便。algolia可以尝试一下,但是对引导文件的大小有限制,涉及到付费升级,也不想用。

这里我使用的是中文分词器,在此借鉴了这个项目:https://github.com/naah69/Hugo-Algolia-Chinese-Builder

根据项目的readme,下载到hugo的项目目录,不需要编写config.yaml文件,需要在config.toml添加:

[outputs]
  # 增加 JSON 配置
  home = ["HTML", "RSS", "JSON"]

即可根据日常打包的命令,如hugo -D,去生成对应的静态文件,接着即可在生成的public目录下找到生成的index.json文件。找到本地生成的这个json文件,即标志此步骤顺利。

添加业务代码

1. html

/theme/cactus/layouts/partials/mysearch.html

<a id="search-click" class="search-sou" href="#">搜</a>

<div id="fastSearch" class="search-container" style="display: none;">
    <div>
        <input class="aa-Input" aria-autocomplete="both" aria-labelledby="autocomplete-0-label"
               id="searchInput" autocomplete="off" autocorrect="off" autocapitalize="off" enterkeyhint="go"
               spellcheck="false" placeholder="Search..." maxlength="512" type="search">
<!--        <input type="text" id="searchInput" placeholder="Search..."/>-->
    </div>
    <ul id="searchResults"></ul>
</div>


<!-- search -->
<link rel="stylesheet" href="{{ "css/mysearch.css" | relURL }}" />
<script src="/js/fuse.min.js"></script> <!-- download and copy over fuse.min.js file from fusejs.io -->
<script src="/js/mysearch.js"></script>

2. css

/theme/cactus/layouts/static/css/mysearch.css

/* 默认样式 */

#search-click {
    position: fixed;
    top: 20px;
    right: 20px;
    font-size: 24px;
    width: 30px;
}

/*.search-sou {*/
/*    position: fixed !important;*/
/*    top: 10px !important; !* adjust this value to move the button up or down *!*/
/*    right: 20px !important; !* adjust this value to move the button left or right *!*/
/*    z-index: 100; !* ensure the button is always on top *!*/
/*}*/


.search-container {
    position: fixed;
    top: 20px;
    right: 20px;
    width: 400px;
    z-index: 9999;
    background-color: transparent ; /* 博客背景颜色 */
    /*opacity: 0; !* 完全透明 *!*/
}

/*e2e0de*/
.search-highlight {
    background-color: #ffc107; /* 高亮背景颜色 */
}

.search-highlight-a {
    background-color: #ffe44d; /* 高亮背景颜色 */
}

.search-highlight-b {
    background-color: #ffc107; /* 高亮背景颜色 */
}


.search-input-wrapper {
    position: relative;
}

#searchInput {
    height: 32px; /* adjust this value as needed */
    width: 100%;
    padding: 10px;
    font-size: 16px;
    outline: none;
    background: #e2e0de;
    font-weight: bold; /* 设置粗体 */
    border: 0px solid #ccc;;
    border-radius: 4px;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}

.search-icon {
    position: absolute;
    top: 50%;
    right: 10px;
    transform: translateY(-50%);
    width: 20px;
    height: 20px;
    fill: #888;
    pointer-events: none;
}

#searchResults {
    /*list-style-type: none;*/
    padding: 0;
    margin: 0;
    max-height: 900px; /* 设置最大高度 */
    overflow-y: auto; /* 显示垂直滚动条 */
    -webkit-overflow-scrolling: touch; /* 启用平滑滚动 */
    margin-top: 10px !important; /* 你可以调整这个值来改变间距 */
    border: 0px solid #ccc;;
    border-radius: 5px;
    box-shadow: 2px 2px 5px rgba(0.8, 0, 0, 0.1);
    background-color: #e2e0de;
}

/* 移动设备样式 */
@media (max-width: 768px) {
    #searchResults {
        max-height: 500px;
    }
}

#searchResults::-webkit-scrollbar {
  width: 10px; /* 设置滚动条宽度 */
}

#searchResults::-webkit-scrollbar-track {
  background-color: #f1f1f1; /* 滚动条轨道背景颜色 */
}

#searchResults::-webkit-scrollbar-thumb {
  background-color: #888; /* 滚动条拖动块背景颜色 */
  border-radius: 5px; /* 滚动条拖动块边框半径 */
}

#searchResults::-webkit-scrollbar-thumb:hover {
  background-color: #555; /* 鼠标悬停时的滚动条拖动块背景颜色 */
}
#searchResults li {
    padding: 10px;
    /*background-color: #f8f9fa;*/
    transition: background-color 0.3s ease; /* 添加过渡效果 */
}

#searchResults li:hover {
    background-color: #e9ecef; /* 设置悬停时的背景颜色 */
}

#searchResults li a {
    color: #333;
    text-decoration: none;
}

#searchResults li a:hover {
    text-decoration: underline;
}


/* 移动设备样式 */
@media (max-width: 768px) {
    .search-container {
        width: 100%;
        max-width: 300px;
        left: 50%;
        transform: translateX(-50%);
    }
}

3. js

/theme/cactus/layouts/static/js/mysearch.js

// loadSearch()
// 初始化全局变量
var fuse; // holds our search engine
var searchVisible = false;
var firstRun = true; // allow us to delay loading json data unless search activated
var list = document.getElementById('searchResults'); // targets the <ul>
var first = list.firstChild; // first child of search list
var last = list.lastChild; // last child of search list
var maininput = document.getElementById('searchInput'); // input box for search
var resultsAvailable = false; // Did we get any search results?

// 主键盘事件监听器
document.addEventListener('keydown', function (event) {
    // CMD-/ 切换搜索可见性
    if (event.metaKey && event.which === 191) {
        showSearch();
    }

    // ESC 关闭搜索框
    if (event.keyCode == 27) {
        if (searchVisible) {
            document.getElementById("fastSearch").style.display = "none";
            document.activeElement.blur();
            searchVisible = false;
        }
    }

    // 方向键上下移动
    if (event.keyCode == 38 || event.keyCode == 40) {
        if (searchVisible && resultsAvailable) {
            event.preventDefault();
            var active = document.activeElement;
            if (event.keyCode == 40) {
                if (active == maininput) {
                    first.focus();
                } else if (active == last) {
                    last.focus();
                } else {
                    active.parentElement.nextSibling.firstElementChild.focus();
                }
            } else if (event.keyCode == 38) {
                if (active == maininput) {
                    maininput.focus();
                } else if (active == first) {
                    maininput.focus();
                } else {
                    active.parentElement.previousSibling.firstElementChild.focus();
                }
            }
        }
    }
});

// 当输入时执行搜索
document.getElementById("searchInput").onkeyup = function (e) {
    executeSearch(this.value);
}

// 点击事件监听器,检查点击位置并隐藏搜索框
document.addEventListener('click', function (event) {
    var searchContainer = document.getElementById('fastSearch');
    var searchInput = document.getElementById('searchInput');
    var sDom = document.getElementById('search-click');

    // 检查点击事件发生的位置
    var isClickedOutsideSearch = !searchContainer.contains(event.target);

    if (isClickedOutsideSearch) {
        // 隐藏搜索框
        searchContainer.style.display = 'none';
        searchInput.value = '';
        searchVisible = false;
        // 显示 搜
        sDom.style.display = 'block'; // 隐藏
    }
});

// 点击 搜 时,展示搜索框,隐藏 搜
document.addEventListener("click", event => {
    var cDom = document.getElementById("fastSearch");
    var sDom = document.getElementById('search-click');
    var tDom = event.target;
    if (sDom == tDom || sDom.contains(tDom)) {
        sDom.style.display = 'none'; // 隐藏 搜
        showSearch();

    } else if (cDom == tDom || cDom.contains(tDom)) {
        // ...
    } else if (searchVisible) {
        cDom.style.display = "none"
        searchVisible = false;
    }
});


// 显示搜索框,并在首次运行时加载搜索引擎
function showSearch() {
    if (firstRun) {
        loadSearch();
        firstRun = false;
    }
    if (!searchVisible) {
        document.getElementById("fastSearch").style.display = "block";
        document.getElementById("searchInput").focus();
        searchVisible = true;
    } else {
        document.getElementById("fastSearch").style.display = "none";
        document.activeElement.blur();
        searchVisible = false;
    }
}

// 使用 XMLHttpRequest 获取 JSON 文件
function fetchJSONFile(path, callback) {
    var httpRequest = new XMLHttpRequest();
    httpRequest.onreadystatechange = function () {
        if (httpRequest.readyState === 4) {
            if (httpRequest.status === 200) {
                var data = JSON.parse(httpRequest.responseText);
                if (callback) callback(data);
            }
        }
    };
    httpRequest.open('GET', path);
    httpRequest.send();
}

// 加载搜索引擎
function loadSearch() {
    fetchJSONFile('/index.json', function (data) {
        var options = {
            includeMatches: true,
            shouldSort: true,
            ignoreLocation: true,
            keys: [{
                name: 'title',
                weight: 1,
            }, {
                name: 'content',
                weight: 0.6,
            }],
        };
        fuse = new Fuse(data, options);
    });
}

// 执行搜索
function executeSearch(term) {
    if (term.length == 0) {
        document.getElementById("searchResults").setAttribute("style", "");
        return;
    }
    let results = fuse.search(term);
    // ...
    let searchItems = '';
    if (results.length === 0) {
        resultsAvailable = false;
        searchItems = '<li class="noSearchResult">无结果</li>';
    } else {
        permalinkList = []
        searchItemCount = 0
        for (let item in results) {
            if (permalinkList.includes(results[item].item.permalink)) {
                continue;
            }
            permalinkList.push(results[item].item.permalink);
            searchItemCount += 1;

            title = '<span class="search-highlight-a">' + results[item].item.title + '</span>';
            content = results[item].item.content.slice(0, 50);
            for (const match of results[item].matches) {
                if (match.key == 'title') {
                    startIndex = match.indices[0][0];
                    endIndex = match.indices[0][1] + 1;
                    highText = '<span class="search-highlight">' + match.value.slice(startIndex, endIndex) + '</span>';
                    // title = match.value.slice(0, startIndex) + highText + match.value.slice(endIndex);
                } else if (match.key == 'content') {
                    startIndex = match.indices[0][0];
                    endIndex = match.indices[0][1] + 1;
                    highText = '<span class="search-highlight-b">' + match.value.slice(startIndex, endIndex) + '</span>';
                    content = match.value.slice(Math.max(0, startIndex - 30), startIndex) + highText + match.value.slice(endIndex, endIndex + 30);
                }
            }
            searchItems = searchItems + '<li><a href="' + results[item].item.permalink + '">' + '<span class="title">' + title + '</span><br /> <span class="sc">' + content + '</span></a></li>';
            // 只显示前 5 个结果
            if (searchItemCount >= 1000) {
                break;
            }
        }
        resultsAvailable = true;
    }
    document.getElementById("searchResults").setAttribute("style", "display: block;");
    document.getElementById("searchResults").innerHTML = searchItems;
    if (results.length > 0) {
        first = list.firstChild.firstElementChild;
        last = list.lastChild.firstElementChild;
    }
}

最后在这个文件中插入{{ partial "mysearch.html" . }}

theme/cactus/layouts/_default/baseof.html

<!DOCTYPE html>
<!--<html lang="{{ .Site.LanguageCode }}">-->
<html>

{{ partial "head.html" . }}

<body class="max-width mx-auto px3 ltr">
  <div class="content index py4">

    {{ partial "mysearch.html" . }}

    {{ partial "header.html" . }}

    {{ block "main" . }}

    {{ end }}

    {{ partial "footer.html" . }}

  </div>

  {{ if .Site.GoogleAnalytics }}
    {{ if .Site.Params.googleAnalyticsAsync }}
      {{ template "_internal/google_analytics_async.html" . }}
    {{ else }}
      {{ template "_internal/google_analytics.html" . }}
    {{ end }}
  {{ end }}

</body>

</html>

这些工作都做好之后,来看看效果吧!在主页右上角的地方就可以找到这个「搜」的功能啦,我个人觉得这样特别有味道,简单直接但富有内涵,且耐看。

image-20230518114101241

移动端则是这样的

IMG_2341

后记

这个功能的设计还有美化,受到这位无灯老师(https://ttys3.dev/)的启发,很多界面的设计都大程度地模仿了他的。

另外本次开发这个站内搜索的功能,得到了chatgpt的倾力协助,整个开发过程的很多功能设计都是它帮我生成的,给了很多代码实例,比如很多js、css的代码。通常情况就是我提出想要达到一个什么样的功能,然后让他一什么样的要求去提供代码,在面对错误的答案时提醒自纠,改进代码的质量。在给出代码的基础上,再加上自己的修改。

当然整个使用的过程,也发现了他很多的局限性,比如给出的代码其实不能做到马上应用,需要你来我往地多次提交反馈,让他不断自我纠错和改进去达到我想要的效果。

如果你是一个有一定相关的开发基础,但离单独开发一个功能还有距离,那么其实根据自己的知识,加上gpt的帮助,确实能够帮你快速去实现这样一个你不太熟悉的功能设计。

更多关于博客的精彩文章请戳:


2354 字