From 4baf51f4b183f4424256bd7990806203d0830d8b Mon Sep 17 00:00:00 2001 From: Vaica <94612053+zqlit@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:20:39 +0800 Subject: [PATCH] =?UTF-8?q?added=20=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../index.md | 124 ++++++ hugo.toml | 2 +- themes/Ying/assets/css/main.css | 387 ++++++++++++++++++ themes/Ying/assets/js/main.js | 241 +++++++++++ themes/Ying/layouts/_default/home.html | 17 +- themes/Ying/layouts/index.json | 11 + themes/Ying/layouts/partials/footer.html | 23 ++ 7 files changed, 803 insertions(+), 2 deletions(-) create mode 100644 content/post/2026-01-28-hugo-search-implementation/index.md create mode 100644 themes/Ying/layouts/index.json diff --git a/content/post/2026-01-28-hugo-search-implementation/index.md b/content/post/2026-01-28-hugo-search-implementation/index.md new file mode 100644 index 00000000..9c972c02 --- /dev/null +++ b/content/post/2026-01-28-hugo-search-implementation/index.md @@ -0,0 +1,124 @@ +--- +title: "Hugo静态博客如何实现搜索功能" +date: 2026-01-28 +description: "文章慢慢多了起来,有时候自己想找以前写的内容都得翻半天。于是抽空给博客加了个简单的搜索功能,没用什么高大上的第三方服务,纯前端实现,简单好用。" +tags: ["Hugo", "Search", "PJAX", "折腾"] +categories: ["建站"] +status: public +allowComment: true +--- + +最近发现博客文章数量慢慢上来了,有时候想找自己以前写的某个知识点,翻半天都翻不到,确实有点不方便。 + +其实早就想加个搜索功能了,但看了一圈,像 Algolia 这种第三方服务虽然强大,但配置起来感觉有点繁琐,而且对于咱们这种访问量不大的静态博客来说,是不是有点“杀鸡用牛刀”了? + +想了想,还是决定用最简单的方式来实现:直接生成一个 JSON 索引文件,用 JS 在前端搜一下。虽然比较基础,但胜在轻量,而且完全可控。 + +折腾了几个小时,终于搞定了,顺便把交互做得稍微优化了一下,支持了 PJAX 和暗黑模式。这里简单记录一下实现过程,希望能给同样在折腾 Hugo 的朋友一点参考。 + +## 效果展示 + +为了不破坏页面的简洁感,我把搜索入口藏在了两个地方: + +1. **右下角悬浮工具栏**:点那个放大镜图标,会弹出一个搜索框。 +2. **首页“随笔”标题**:这个算是个小彩蛋吧。在首页找到“随笔”这两个字,鼠标放上去会有“点击搜索”的提示,点一下就能直接在原地搜索,不用跳转。 + +## 实现思路 + +原理其实非常简单,主要就三步: + +1. 让 Hugo 构建时多生成一个 `index.json` 文件,里面包含所有文章的标题、链接和摘要。 +2. 前端 JS 通过 `fetch` 请求这个 JSON 文件。 +3. 根据用户输入的关键词,在 JSON 数据里进行匹配,然后把结果渲染出来。 + +## 代码实现 + +### 1. 配置 Hugo 输出 JSON + +首先在 `hugo.toml` (或者 `config.toml`) 里配置一下输出格式,告诉 Hugo 首页除了 HTML 还要输出 JSON。 + +```toml +[outputs] + home = ["HTML", "RSS", "JSON"] +``` + +### 2. 创建 JSON 模板 + +在主题的 `layouts/index.json` 创建一个模板文件,定义 JSON 的数据结构。这里我只取了标题、日期、链接和摘要,尽量让文件小一点。 + +```go +[ + {{- range $index, $e := where .Site.RegularPages "Type" "post" -}} + {{- if $index -}}, {{- end -}} + { + "title": {{ .Title | jsonify }}, + "date": {{ .Date.Format "2006-01-02" | jsonify }}, + "permalink": {{ .Permalink | jsonify }}, + "summary": {{ .Summary | plainify | jsonify }} + } + {{- end -}} +] +``` + +这样每次 `hugo` 构建的时候,网站根目录就会生成一个 `index.json`。 + +### 3. 前端 JS 逻辑 + +逻辑主要在 `main.js` 里。为了提升体验,我做了一些小优化: + +* **懒加载**:不会一打开网页就下载 JSON,而是等你点击搜索按钮时才去加载,帮大家省点流量。 +* **防抖处理**:输入时不会每敲一个字都去搜索,而是稍微停顿一下再触发,避免频繁计算。 +* **PJAX 适配**:因为博客用了 PJAX 做无刷新跳转,一开始遇到个坑,点击搜索结果页面会白屏或者刷新。后来发现需要在渲染结果后,手动调用一下 `window.pjax.refresh()`,让 PJAX 重新接管这些新生成的链接。 + +```javascript +// 简单的搜索逻辑 +const results = window.searchIndex.filter(item => { + // 简单的关键词匹配 + return item.title.includes(query) || item.summary.includes(query); +}); +displayResults(results); +``` + +### 4. 那个“随笔”变搜索框的交互 + +这个交互其实主要是 CSS 在控制。 + +默认是一个普通的 `

` 标题,点击后通过 JS 隐藏标题,显示输入框。为了过渡自然一点,加了一些简单的 CSS 动画。 + +```css +/* 默认隐藏提示 */ +.search-hint { + opacity: 0; + transition: all 0.3s ease; +} + +/* 鼠标放上去显示 */ +.section-header.normal-mode:hover .search-hint { + opacity: 1; + transform: translateX(0); +} +``` + +为了照顾移动端用户,我在 CSS 里加了媒体查询,手机上直接显示“点击搜索”的提示,不然手机上没有 hover 状态,可能会不知道这里可以点。 + +## 遇到的坑 + +折腾过程中最大的坑还是 **PJAX**。 + +一开始写好的时候,搜索功能本身没问题,但是点搜索结果跳转的时候,要么页面刷新,要么音乐播放器断了。查了半天文档,才发现是因为动态插入的 HTML(搜索结果列表),PJAX 默认是监听不到的。 + +解决办法就是在插入 HTML 后,手动通知一下 PJAX: + +```javascript +if (window.pjax) window.pjax.refresh(searchResults); +``` + +另外,为了体验好一点,我还加了个逻辑:点击搜索结果后,自动关闭搜索框,不然跳转完搜索框还挡在那里,确实有点傻。 + +## 总结 + +虽然只是个小功能,但前前后后也调了好久,特别是细节上的打磨。不过看到最后丝滑的搜索体验,感觉还是挺值得的。 + +如果你也在用 Hugo,不想接复杂的第三方搜索,不妨试试这个方案,轻量、简单,完全够用了。 + +代码就不全贴了,核心逻辑都在上面,有兴趣的朋友可以自己折腾一下。如果有更好的实现方式,也欢迎留言交流哈! diff --git a/hugo.toml b/hugo.toml index c88c7116..38baba25 100644 --- a/hugo.toml +++ b/hugo.toml @@ -69,7 +69,7 @@ hasCJKLanguage = true # 输出格式配置 (Outputs) [outputs] - home = ["HTML", "RSS"] + home = ["HTML", "RSS", "JSON"] # RSS 媒体类型定义 (RSS Media Type) [mediaTypes."application/rss+xml"] diff --git a/themes/Ying/assets/css/main.css b/themes/Ying/assets/css/main.css index 3e8be4a8..f710bf51 100644 --- a/themes/Ying/assets/css/main.css +++ b/themes/Ying/assets/css/main.css @@ -3269,6 +3269,393 @@ html[data-theme="light"] .atk-loading-spinner svg circle { .artalk.hidden { display: none; } +/* Search Modal Styles */ +.search-modal { + display: none; /* Hidden by default */ + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */ + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + opacity: 0; + transition: opacity 0.3s ease; +} + +.search-modal.show { + display: flex; + opacity: 1; + align-items: center; + justify-content: center; +} + +.search-modal-content { + background-color: #fefefe; + margin: auto; + padding: 20px; + border: 1px solid #888; + width: 80%; + max-width: 600px; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + transform: translateY(-20px); + transition: transform 0.3s ease; +} + +.search-modal.show .search-modal-content { + transform: translateY(0); +} + +.search-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + border-bottom: 1px solid #eee; + padding-bottom: 10px; +} + +.search-header h3 { + margin: 0; + font-size: 1.2rem; + color: #333; +} + +.close-search { + color: #aaa; + font-size: 28px; + font-weight: bold; + cursor: pointer; + line-height: 1; +} + +.close-search:hover, +.close-search:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + +.search-input-wrapper { + display: flex; + margin-bottom: 20px; + border: 1px solid #ddd; + border-radius: 8px; + padding: 5px 10px; + background: #f9f9f9; +} + +.search-input-wrapper input { + flex: 1; + border: none; + background: transparent; + padding: 10px; + font-size: 16px; + outline: none; +} + +.search-input-wrapper button { + background: none; + border: none; + cursor: pointer; + color: #666; + padding: 0 10px; +} + +.search-results { + max-height: 400px; + overflow-y: auto; +} + +.search-result-item { + padding: 15px; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s; +} + +.search-result-item:last-child { + border-bottom: none; +} + +.search-result-item:hover { + background-color: #f5f5f5; +} + +.search-result-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 5px; + display: block; + color: #333; + text-decoration: none; +} + +.search-result-title:hover { + color: var(--text-highlight-color); +} + +.search-result-date { + font-size: 12px; + color: #999; + margin-bottom: 5px; + display: block; +} + +.search-result-summary { + font-size: 14px; + color: #666; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.search-tip { + text-align: center; + color: #999; + padding: 20px; +} + +/* Dark Mode Adaptation */ +[data-theme="dark"] .search-modal-content { + background-color: #2d2d2d; + border-color: #444; + color: #e0e0e0; +} + +[data-theme="dark"] .search-header { + border-bottom-color: #444; +} + +[data-theme="dark"] .search-header h3 { + color: #e0e0e0; +} + +[data-theme="dark"] .close-search { + color: #888; +} + +[data-theme="dark"] .close-search:hover { + color: #fff; +} + +[data-theme="dark"] .search-input-wrapper { + background: #333; + border-color: #555; +} + +[data-theme="dark"] .search-input-wrapper input { + color: #e0e0e0; +} + +[data-theme="dark"] .search-input-wrapper button { + color: #aaa; +} + +[data-theme="dark"] .search-result-item { + border-bottom-color: #444; +} + +[data-theme="dark"] .search-result-item:hover { + background-color: #383838; +} + +[data-theme="dark"] .search-result-title { + color: #e0e0e0; +} + +[data-theme="dark"] .search-result-summary { + color: #aaa; +} + +/* Inline Search Styles */ +.searchable-header-container { + position: relative; + margin-top: 15px; + height: 13px; + display: flex; + align-items: center; +} + +.section-header.normal-mode { + cursor: pointer; + transition: opacity 0.3s ease; + margin: 0; + user-select: none; + line-height: 1; +} + +.section-header.normal-mode:hover { + opacity: 0.7; +} + +.search-hint { + font-size: 12px; + font-weight: normal; + color: #999; + margin-left: 10px; + opacity: 0; /* Hidden by default */ + transform: translateX(-5px); + transition: all 0.3s ease; + display: inline-block; + vertical-align: middle; +} + +.section-header.normal-mode:hover .search-hint { + opacity: 1; + transform: translateX(0); +} + +/* Mobile: always show hint to ensure discoverability */ +@media (max-width: 768px) { + .search-hint { + opacity: 0.6; + transform: translateX(0); + } +} + +.search-mode { + width: 100%; + animation: fadeIn 0.3s ease; +} + +.search-input-group { + display: flex; + align-items: center; + background: transparent; + border-bottom: 1px solid #e5e7eb; + padding: 0 5px; + width: 100%; + max-width: 300px; + transition: all 0.3s ease; +} + +.search-input-group:focus-within { + background: transparent; + border-bottom-color: var(--text-highlight-color); + box-shadow: none; +} + +.search-input-group input { + border: none; + background: transparent; + flex: 1; + padding: 5px 10px; + font-size: 14px; + outline: none; + color: #333; +} + +.search-input-group .search-icon { + color: #999; + font-size: 16px; +} + +.search-input-group .close-icon { + color: #999; + cursor: pointer; + font-size: 16px; + padding: 5px; +} + +.search-input-group .close-icon:hover { + color: #333; +} + +.search-dropdown { + position: absolute; + top: 100%; + left: 0; + width: 100%; + max-width: 300px; + background: #fff; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + z-index: 100; + max-height: 300px; + overflow-y: auto; + display: none; + margin-top: 5px; + border: 1px solid #eee; +} + +.search-dropdown .search-result-item { + padding: 10px 15px; + font-size: 14px; + display: block; /* Ensure it behaves like a block */ + text-decoration: none; /* Remove underline */ + color: inherit; /* Inherit text color */ + cursor: pointer; +} + +.search-dropdown .search-result-item:hover { + background-color: #f5f5f5; + text-decoration: none; +} + +.search-dropdown .search-result-title { + font-size: 15px; + font-weight: 600; + display: block; /* Ensure title is block to break line */ + color: #333; +} + +.search-dropdown .search-result-summary { + font-size: 12px; + -webkit-line-clamp: 1; +} + +/* Dark Mode for Inline Search */ +[data-theme="dark"] .search-input-group { + background: transparent; + border-bottom-color: #444; +} + +[data-theme="dark"] .search-input-group:focus-within { + background: transparent; + border-bottom-color: var(--text-highlight-color); +} + +[data-theme="dark"] .search-input-group input { + color: #e0e0e0; +} + +[data-theme="dark"] .search-input-group .search-icon, +[data-theme="dark"] .search-input-group .close-icon { + color: #888; +} + +[data-theme="dark"] .search-input-group .close-icon:hover { + color: #fff; +} + +[data-theme="dark"] .search-dropdown { + background: #2d2d2d; + border-color: #444; +} + +[data-theme="dark"] .search-dropdown .search-result-item { + color: #e0e0e0; +} + +[data-theme="dark"] .search-dropdown .search-result-item:hover { + background-color: #383838; +} + +[data-theme="dark"] .search-dropdown .search-result-title { + color: #e0e0e0; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateX(-10px); } + to { opacity: 1; transform: translateX(0); } +} + diff --git a/themes/Ying/assets/js/main.js b/themes/Ying/assets/js/main.js index ac39c190..34ef5adb 100644 --- a/themes/Ying/assets/js/main.js +++ b/themes/Ying/assets/js/main.js @@ -461,3 +461,244 @@ function scrollToCommentsAndQuote(text) { }, 500); } } + +/* Search Functionality */ +function initSearchFunctionality() { + const searchBtn = document.getElementById('search-btn'); + const searchModal = document.getElementById('search-modal'); + const closeSearch = document.querySelector('.close-search'); + const searchInput = document.getElementById('search-input'); + const searchResults = document.getElementById('search-results'); + const searchSubmit = document.getElementById('search-submit'); + + // Global variables (window scope) to persist across re-inits if needed, + // but better to keep them scoped if we re-fetch. + // For simplicity, we keep index loading check. + if (!window.searchIndex) window.searchIndex = []; + if (!window.isIndexLoaded) window.isIndexLoaded = false; + + if (!searchBtn || !searchModal) return; + + // Open Modal + // Use named function to avoid duplicate listeners if we didn't clean up (though init is usually called once per page load/pjax) + function openModal() { + searchModal.classList.add('show'); + if (!window.isIndexLoaded) { + loadSearchIndex(); + } + setTimeout(() => searchInput.focus(), 100); + } + searchBtn.removeEventListener('click', openModal); // Prevent duplicates + searchBtn.addEventListener('click', openModal); + + // Close Modal + function closeModal() { + searchModal.classList.remove('show'); + } + + if (closeSearch) { + closeSearch.onclick = closeModal; + } + + // Close when clicking outside + window.onclick = function(e) { + if (e.target === searchModal) { + closeModal(); + } + }; + + // Close on Escape key + document.onkeydown = function(e) { + if (e.key === 'Escape' && searchModal.classList.contains('show')) { + closeModal(); + } + }; + + // Load Index + function loadSearchIndex() { + fetch('/index.json') + .then(response => response.json()) + .then(data => { + window.searchIndex = data; + window.isIndexLoaded = true; + }) + .catch(error => { + console.error('Error loading search index:', error); + if(searchResults) searchResults.innerHTML = '

搜索索引加载失败

'; + }); + } + + // Perform Search + function performSearch() { + if(!searchInput) return; + const query = searchInput.value.trim().toLowerCase(); + if (query === '') { + searchResults.innerHTML = '

输入关键词并回车进行搜索

'; + return; + } + + if (!window.isIndexLoaded) { + searchResults.innerHTML = '

正在加载索引...

'; + // Retry after a short delay if index is loading + setTimeout(performSearch, 500); + return; + } + + const results = window.searchIndex.filter(item => { + const title = item.title ? item.title.toLowerCase() : ''; + const summary = item.summary ? item.summary.toLowerCase() : ''; + return title.includes(query) || summary.includes(query); + }); + + displayResults(results); + } + + // Display Results + function displayResults(results) { + if (results.length === 0) { + searchResults.innerHTML = '

未找到相关文章

'; + return; + } + + let html = ''; + results.forEach(item => { + html += ` + + ${item.title} + ${item.date} +
${item.summary}
+
+ `; + }); + searchResults.innerHTML = html; + if (window.pjax) window.pjax.refresh(searchResults); + + // Add click listener to close modal on result click + const resultLinks = searchResults.querySelectorAll('.search-result-item'); + resultLinks.forEach(link => { + link.addEventListener('click', function() { + closeModal(); + }); + }); + } + + // Event Listeners for Search + if (searchSubmit) { + searchSubmit.onclick = performSearch; + } + + if (searchInput) { + searchInput.onkeypress = function(e) { + if (e.key === 'Enter') { + performSearch(); + } + }; + + // Optional: Live search with debounce + let debounceTimer; + searchInput.oninput = function() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(performSearch, 300); + }; + } + + /* Inline Search Logic */ + const essayHeader = document.getElementById('essay-header'); + const searchContainer = document.getElementById('search-container'); + const inlineSearchInput = document.getElementById('inline-search-input'); + const inlineSearchResults = document.getElementById('inline-search-results'); + + if (essayHeader && searchContainer && inlineSearchInput) { + window.toggleSearchMode = function(show) { + if (show) { + essayHeader.style.display = 'none'; + searchContainer.style.display = 'block'; + inlineSearchInput.focus(); + if (!window.isIndexLoaded) { + loadSearchIndex(); + } + } else { + essayHeader.style.display = 'block'; + searchContainer.style.display = 'none'; + inlineSearchInput.value = ''; + inlineSearchResults.style.display = 'none'; + } + }; + + // Close when clicking outside - delegated to document body to handle pjax updates + document.body.onclick = function(e) { + const isClickInside = essayHeader.contains(e.target) || + searchContainer.contains(e.target) || + inlineSearchResults.contains(e.target); + + if (!isClickInside && searchContainer.style.display === 'block') { + window.toggleSearchMode(false); + } + // Also handle modal outside click here if needed, but window.onclick handles that + if (e.target === searchModal) { + closeModal(); + } + }; + + // Inline Search Function + function performInlineSearch() { + const query = inlineSearchInput.value.trim().toLowerCase(); + if (query === '') { + inlineSearchResults.style.display = 'none'; + return; + } + + if (!window.isIndexLoaded) { + // Wait if loading + setTimeout(performInlineSearch, 500); + return; + } + + const results = window.searchIndex.filter(item => { + const title = item.title ? item.title.toLowerCase() : ''; + const summary = item.summary ? item.summary.toLowerCase() : ''; + return title.includes(query) || summary.includes(query); + }); + + displayInlineResults(results); + } + + function displayInlineResults(results) { + if (results.length === 0) { + inlineSearchResults.innerHTML = '
未找到相关文章
'; + inlineSearchResults.style.display = 'block'; + return; + } + + let html = ''; + results.slice(0, 5).forEach(item => { // Limit to 5 inline results + html += ` + + ${item.title} +
${item.summary}
+
+ `; + }); + inlineSearchResults.innerHTML = html; + inlineSearchResults.style.display = 'block'; + if (window.pjax) window.pjax.refresh(inlineSearchResults); + } + + let inlineDebounce; + inlineSearchInput.oninput = function() { + clearTimeout(inlineDebounce); + inlineDebounce = setTimeout(performInlineSearch, 300); + }; + + // Handle Escape key + inlineSearchInput.onkeydown = function(e) { + if (e.key === 'Escape') { + window.toggleSearchMode(false); + } + }; + } +} + +document.addEventListener('DOMContentLoaded', initSearchFunctionality); +// Re-init on Pjax complete +document.addEventListener('pjax:complete', initSearchFunctionality); diff --git a/themes/Ying/layouts/_default/home.html b/themes/Ying/layouts/_default/home.html index d8265dc7..ef5a90d8 100644 --- a/themes/Ying/layouts/_default/home.html +++ b/themes/Ying/layouts/_default/home.html @@ -2,7 +2,22 @@
-

随笔

+ +
+

+ 随笔 + 点击随笔,开始搜索 +

+ +
+
+
{{ $posts := sort (where (where .Site.RegularPages "Type" "post") "Params.status" "ne" "hidden") diff --git a/themes/Ying/layouts/index.json b/themes/Ying/layouts/index.json new file mode 100644 index 00000000..a3ed64cf --- /dev/null +++ b/themes/Ying/layouts/index.json @@ -0,0 +1,11 @@ +[ + {{- range $index, $e := where .Site.RegularPages "Type" "post" -}} + {{- if $index -}}, {{- end -}} + { + "title": {{ .Title | jsonify }}, + "date": {{ .Date.Format "2006-01-02" | jsonify }}, + "permalink": {{ .Permalink | jsonify }}, + "summary": {{ .Summary | plainify | jsonify }} + } + {{- end -}} +] \ No newline at end of file diff --git a/themes/Ying/layouts/partials/footer.html b/themes/Ying/layouts/partials/footer.html index 67ad8db0..e2225f13 100644 --- a/themes/Ying/layouts/partials/footer.html +++ b/themes/Ying/layouts/partials/footer.html @@ -14,6 +14,9 @@
+ + + @@ -24,6 +27,26 @@
+ + +
+
+
+

搜索文章

+ × +
+
+
+ + +
+
+ +

输入关键词并回车进行搜索

+
+
+
+
{{ $iconfont := resources.Get "js/iconfont.js" }} {{ $lightgallery := resources.Get "js/lightgallery.umd.min.js" }} {{ $uikit := resources.Get "js/uikit.min.js" }}