hexo-theme-volantis
Version:
Elegant and powerful theme for Hexo.
727 lines (652 loc) • 30.9 kB
JavaScript
/**
* 搜索服务类(单例模式)
* 负责搜索界面渲染、事件绑定、搜索数据处理、结果展示、搜索历史管理
* 支持多类型筛选(全部/文章/页面/标签)、关键词高亮、localStorage存储搜索历史
*/
class SearchService {
/**
* 单例实例,确保全局唯一的搜索服务实例
* @type {SearchService|null}
*/
static instance = null;
/**
* 构造函数(单例模式核心:防止重复创建实例)
* 初始化搜索相关属性、多语言文本、搜索界面模板,执行实例初始化
*/
constructor() {
// 单例判断:若已存在实例,直接返回现有实例,避免重复创建
if (!SearchService.instance) {
this.queryText = null; // 当前搜索关键词
this.data = null; // 搜索数据源(文章/页面/标签等)
this.searchHistory = []; // 搜索历史数组
this.maxHistoryCount = 5; // 最大搜索历史记录数(防止过多)
this.isComposing = false; // 输入法组合状态标识(处理中文输入时的特殊事件)
this.debouncedInputHandler = this.debounce(event => {
if (!this.isComposing) this.onSubmit(event);
}, 300);
// 多语言文本配置(优先使用全局配置,无则用默认值)
const searchLang = volantis.GLOBAL_CONFIG?.languages?.search || {};
this.hitsEmpty = searchLang.hits_empty || '未找到与「${query}」相关的内容';
this.hitsFound = searchLang.hits_found || '找到 ${totalResults} 个相关结果';
this.normalText = searchLang.placeholder || '搜索文章、标签或分类...';
this.loadDataText = searchLang.normal || '正在加载数据...';
this.searchingText = searchLang.searching || '搜索中...';
this.searchHistoryText = searchLang.history || '搜索历史';
this.noSearchHistoryText = searchLang.no_history || '暂无搜索历史';
this.clearHistoryText = searchLang.clear_history || '清除历史';
this.filterAll = searchLang.filter_all || '全部';
this.filterPosts = searchLang.filter_posts || '文章';
this.filterPages = searchLang.filter_pages || '页面';
this.filterTags = searchLang.filter_tags || '标签';
this.labelPosts = searchLang.label_posts || '文章';
this.labelPages = searchLang.label_pages || '页面';
this.labelTags = searchLang.label_tags || '标签';
// 空查询时的默认显示内容
this.normal = `<div class="result-hits-empty"><p>${this.normalText}🔍</p></div>`;
// 搜索界面HTML模板(模态框结构:输入区+筛选区+结果区+历史区+加载区+遮罩层)
this.template = `
<div class="modal">
<header class="modal-header clearfix">
<form id="u-search-modal-form" class="u-search-form" name="uSearchModalForm">
<input type="text" id="u-search-modal-input" class="u-search-input" placeholder="${this.normalText}" autocomplete="off" />
<button type="submit" id="u-search-modal-btn-submit" class="u-search-btn-submit">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
</form>
<a id="u-search-btn-close" class="btn-close">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</a>
</header>
<div class="search-filters">
<button class="filter-btn active" data-filter="all">${this.filterAll}</button>
<button class="filter-btn" data-filter="posts">${this.filterPosts}</button>
<button class="filter-btn" data-filter="pages">${this.filterPages}</button>
<button class="filter-btn" data-filter="tags">${this.filterTags}</button>
</div>
<main class="modal-body">
<div class="search-stats hidden"></div>
<ul class="modal-results"></ul>
<div class="search-history hidden">
<h3>${this.searchHistoryText}</h3>
<button class="clear-history">${this.clearHistoryText}</button>
<ul class="history-list"></ul>
</div>
<div class="search-loading hidden">
<div class="spinner"></div>
<p>${this.loadDataText}</p>
</div>
</main>
</div>
<div id="modal-overlay" class="modal-overlay"></div>`;
// 初始化实例(创建DOM、绑定事件、加载历史)
this.initInstance();
// 赋值单例实例,确保后续调用获取同一实例
SearchService.instance = this;
}
// 返回单例实例(无论是否新建,确保全局唯一)
return SearchService.instance;
}
/**
* 静态方法:初始化单例实例
* 防止外部直接new创建,统一通过该方法初始化
*/
static init() {
if (!SearchService.instance) {
new SearchService();
}
}
/**
* 实例初始化核心方法
* 1. 创建搜索DOM元素(若不存在)
* 2. 绑定所有交互事件
* 3. 加载localStorage中的搜索历史
* 4. 更新搜索历史的DOM显示
* 5. 初始化暗黑模式状态并设置监听器
*/
initInstance() {
// 若页面中无#u-search元素,创建并添加到body
if (!document.querySelector('#u-search')) {
const div = document.createElement("div");
div.id = "u-search"; // 搜索容器ID
div.innerHTML = this.template; // 插入搜索界面模板
document.body.append(div); // 添加到页面body
}
this.bindEvents(); // 绑定所有交互事件
this.loadSearchHistory(); // 从localStorage加载搜索历史
this.updateHistoryDisplay(); // 更新搜索历史的DOM显示
}
/**
* 绑定所有搜索相关交互事件
* 包括:输入框事件、表单提交、关闭/遮罩点击、筛选切换、历史管理事件
*/
bindEvents() {
const uSearchModalInput = document.querySelector("#u-search-modal-input");
if (!uSearchModalInput.hasAttribute('data-event-bound')) {
uSearchModalInput.setAttribute('data-event-bound', 'true');
uSearchModalInput.addEventListener("compositionstart", () => (this.isComposing = true));
uSearchModalInput.addEventListener("compositionend", (event) => {
this.isComposing = false;
this.onSubmit(event);
});
uSearchModalInput.addEventListener("input", this.debouncedInputHandler.bind(this));
document.querySelectorAll(".u-search-form").forEach((e) => {
e.addEventListener("submit", this.onSubmit.bind(this));
});
document.querySelector("#u-search-btn-close").addEventListener("click", this.close.bind(this));
document.querySelector("#modal-overlay").addEventListener("click", this.close.bind(this));
// 筛选按钮点击事件(全部/文章/页面/标签)
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
this.onSubmit({
preventDefault: () => { },
target: document.querySelector("#u-search-modal-form")
});
});
});
// 清除历史按钮点击事件:清空搜索历史
document.querySelector('.clear-history').addEventListener('click', () => {
this.clearSearchHistory();
});
// 搜索历史列表项点击事件:点击历史关键词填充输入框并触发搜索
document.querySelector('.history-list').addEventListener('click', (e) => {
if (e.target.tagName === 'LI') {
uSearchModalInput.value = e.target.textContent;
this.onSubmit({
preventDefault: () => { },
target: document.querySelector("#u-search-modal-form")
});
}
});
}
}
/**
* 搜索提交处理函数
* 阻止表单默认提交行为,获取搜索关键词,触发搜索逻辑
* @param {Event} event - 提交事件(表单submit/输入框input等)
*/
async onSubmit(event) {
event.preventDefault();
const input = event.target.querySelector(".u-search-input") || event.target;
this.queryText = input.value.trim();
await this.search();
}
/**
* 核心搜索逻辑
* 1. 显示搜索界面和加载状态
* 2. 加载数据源(若未加载)
* 3. 根据筛选条件生成搜索结果
* 4. 显示结果/空状态/搜索历史
* 5. 绑定ESC关闭事件
*/
async search() {
// 获取所有搜索相关DOM元素
const searchInputs = document.querySelectorAll(".u-search-input"); // 所有搜索输入框
const searchModal = document.querySelector("#u-search"); // 搜索容器
const modalOverlay = document.querySelector("#modal-overlay"); // 遮罩层
const modalResults = document.querySelector("#u-search .modal-results"); // 结果列表
const modal = document.querySelector("#u-search .modal"); // 模态框主体
const searchStats = document.querySelector('.search-stats'); // 结果统计
const searchLoading = document.querySelector('.search-loading'); // 加载状态
const searchLoadingText = searchLoading.querySelector('p'); // 加载状态文本
const searchHistory = document.querySelector('.search-history'); // 搜索历史
const activeFilter = document.querySelector('.filter-btn.active').dataset.filter; // 当前筛选条件(all/posts/pages/tags)
// 同步所有输入框的关键词
searchInputs.forEach((input) => (input.value = this.queryText));
// 显示搜索容器和遮罩层毛玻璃效果
searchModal.style.display = "block";
modalOverlay.style.backdropFilter = "blur(10px)";
// 延迟100ms执行模态框显示动画
setTimeout(() => {
modal.style.transform = "translate(0px, 0px)"; // 从下移状态恢复到正常位置
}, 100);
// 显示加载状态,隐藏统计、历史和结果
modalResults.innerHTML = '';
modalResults.style.flex = '';
searchStats.classList.add('hidden');
searchLoading.classList.remove('hidden');
searchHistory.classList.add('hidden');
// 若未加载数据源,异步获取(从配置的dataPath请求)
if (!this.data) {
searchLoadingText.textContent = this.loadDataText;
this.data = await this.fetchData();
} else {
searchLoadingText.textContent = this.searchingText;
}
// 延迟300ms执行结果生成
setTimeout(async () => {
let results = ""; // 最终搜索结果HTML
let totalResults = 0; // 总结果数统计
// 根据当前筛选条件生成对应结果
if (activeFilter === 'all' || activeFilter === 'posts') {
// 生成文章类型结果(调用buildResultList)
const postResults = this.buildResultList(this.data.posts, 'post');
results += postResults.html; // 拼接文章结果HTML
totalResults += postResults.count; // 累加文章结果数
}
if (activeFilter === 'all' || activeFilter === 'pages') {
// 生成页面类型结果(调用buildResultList)
const pageResults = this.buildResultList(this.data.pages, 'page');
results += pageResults.html; // 拼接页面结果HTML
totalResults += pageResults.count; // 累加页面结果数
}
if (activeFilter === 'all' || activeFilter === 'tags') {
// 生成标签类型结果(调用buildTagResults)
const tagResults = this.buildTagResults();
results += tagResults.html; // 拼接标签结果HTML
totalResults += tagResults.count; // 累加标签结果数
}
// 隐藏加载状态(搜索完成)
searchLoading.classList.add('hidden');
// 处理搜索结果显示逻辑
if (!results) {
modalResults.style.flex = 1;
// 无结果:显示空状态提示(替换关键词占位符)
results = `<div class="result-hits-empty no-results">
<i class="fa-solid fa-search"></i>
<p>${this.hitsEmpty.replace(/\$\{query}/, this.queryText)}</p>
</div>`;
searchStats.classList.add('hidden'); // 隐藏统计(无结果无需统计)
} else {
// 有结果:显示结果统计,保存搜索历史(关键词非空时)
searchStats.textContent = this.hitsFound.replace(/\$\{totalResults}/, totalResults);
searchStats.classList.remove('hidden');
// 关键词非空时,添加到搜索历史
if (this.queryText && this.queryText.length > 0) {
this.addToSearchHistory(this.queryText);
}
}
// 空查询(关键词为空):显示默认提示和搜索历史
if (this.queryText === "") {
results = this.normal; // 显示默认提示(如“搜索文章、标签或分类...”)
searchStats.classList.add('hidden'); // 隐藏统计
searchHistory.classList.remove('hidden'); // 显示搜索历史
this.updateHistoryDisplay(); // 更新历史显示(确保最新)
}
// 渲染搜索结果到DOM
modalResults.innerHTML = results;
// 若存在pjax,刷新搜索容器(适配pjax页面切换场景)
window.pjax && pjax.refresh(searchModal);
// 重新初始化懒加载(适配新结果中的图片)
window.lazyLoader && window.lazyLoader.reinitObserver();
// 绑定ESC键关闭搜索模态框(单次有效,关闭后移除事件)
const handleKeydown = (event) => {
if (event.code === "Escape") { // 监听ESC键
this.close(); // 关闭模态框
document.removeEventListener("keydown", handleKeydown); // 移除事件监听(避免内存泄漏)
}
};
document.addEventListener("keydown", handleKeydown);
}, 300);
}
/**
* 关闭搜索模态框
* 执行模态框隐藏动画,延迟隐藏容器和取消遮罩层效果
*/
close() {
const modal = document.querySelector("#u-search .modal"); // 模态框主体
const searchOverlay = document.querySelector("#u-search"); // 搜索容器
const modalOverlay = document.querySelector("#modal-overlay"); // 遮罩层
// 模态框下移动画(隐藏效果)
modal.style.transform = "translateY(120%)";
// 延迟300ms(等待动画完成)后隐藏容器,取消遮罩层毛玻璃
setTimeout(() => {
searchOverlay.style.display = "none";
modalOverlay.style.backdropFilter = "blur(0)";
}, 300);
}
/**
* 异步获取搜索数据源
* 从全局配置的search.dataPath请求JSON数据(文章/页面/标签等)
* @returns {Promise<Object|null>} 数据源JSON对象,失败返回null
*/
async fetchData() {
try {
// 发送GET请求获取数据源
const response = await fetch(volantis.GLOBAL_CONFIG.search.dataPath);
// 解析JSON响应并返回
return await response.json();
} catch (error) {
// 捕获请求/解析错误,打印日志
console.error("Error fetching search data:", error);
return null; // 失败返回null
}
}
/**
* 构建文章/页面类型的搜索结果列表
* @param {Array} data - 数据源数组(文章或页面列表)
* @param {string} type - 结果类型(post:文章,page:页面)
* @returns {Object} { html: 结果HTML字符串, count: 结果数量 }
*/
buildResultList(data, type) {
// 数据或关键词为空时,返回空结果
if (!data || !this.queryText) return { html: '', count: 0 };
// 按更新时间降序排序(最新的结果排在前面)
const sortedData = data.sort((a, b) => new Date(b.updated) - new Date(a.updated));
let results = ''; // 结果HTML字符串
let count = 0; // 结果数量统计
// 遍历排序后的数据源,筛选匹配的结果
sortedData.forEach(item => {
// 调用contentSearch判断当前项是否匹配关键词
if (this.contentSearch(item)) {
// 匹配则生成单个结果项HTML,拼接至结果中
results += this.buildResult(item, type);
count++; // 累加结果数量
}
});
// 若有结果,添加结果分组标题(如“文章 (5)”、“页面 (1)”)
if (results && count > 0) {
const label = type === 'post' ? this.labelPosts : this.labelPages;
results = `<li class="result-group"><h3>${label} (${count})</h3></li>` + results;
}
// 返回结果HTML和数量
return { html: results, count };
}
/**
* 构建标签类型的搜索结果列表
* 匹配标签名中包含关键词的标签,生成标签结果项
* @returns {Object} { html: 结果HTML字符串, count: 结果数量 }
*/
buildTagResults() {
// 标签数据或关键词为空时,返回空结果
if (!this.data.tags || !this.queryText) return { html: '', count: 0 };
const query = this.queryText.toLowerCase(); // 关键词转为小写(不区分大小写匹配)
let results = ''; // 结果HTML字符串
let count = 0; // 结果数量统计
// 遍历标签数据源,筛选匹配的标签
this.data.tags.forEach(tag => {
// 标签名包含关键词(不区分大小写)
if (tag.name.toLowerCase().includes(query)) {
// 生成单个标签结果项HTML(包含图标、高亮关键词的标签名、链接)
results += `
<li class="tag-result">
<a href="${tag.permalink}">
<span class="tag-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" stroke="currentColor">
<line x1="4" y1="9" x2="20" y2="9"></line><line x1="4" y1="15" x2="20" y2="15"></line><line x1="10" y1="3" x2="8" y2="21"></line><line x1="16" y1="3" x2="14" y2="21"></line>
</svg>
</span>
<span class="tag-name">${this.highlightMatch(tag.name)}</span>
</a>
</li>
`;
count++; // 累加结果数量
}
});
// 若有结果,添加标签分组标题(如“标签 (1)”)
if (results && count > 0) {
results = `<li class="result-group"><h3>${this.labelTags} (${count})</h3></li><ul class="result-tags-list">${results}</ul>`;
}
// 返回结果HTML和数量
return { html: results, count };
}
/**
* 判断内容是否匹配关键词(文章/页面通用)
* 匹配范围:标题、内容、描述、标签,匹配则生成高亮摘要
* @param {Object} post - 单个文章/页面对象
* @returns {boolean} 匹配返回true,不匹配返回false
*/
contentSearch(post) {
// 关键词为空时,不匹配
if (!this.queryText) return false;
// 拆分关键词为数组(支持空格/连字符分隔,转为小写)
const keywords = this.queryText.toLowerCase().split(/[-\s]+/);
// 提取文章/页面的关键字段(转为小写,无则为空字符串)
const postTitle = post?.title?.toLowerCase() || '';
const postContent = (post?.content || post?.text)?.toLowerCase() || '';
const postDescription = (post?.description || post?.excerpt)?.toLowerCase() || '';
const postTags = post?.tags?.map(t => t.name.toLowerCase()).join(' ') || '';
// 检查是否包含任一关键词(标题/内容/描述/标签中任一匹配即可)
const foundMatch = keywords.some(word =>
postTitle.includes(word) ||
postContent.includes(word) ||
postDescription.includes(word) ||
postTags.includes(word)
);
// 无匹配关键词,返回false
if (!foundMatch) return false;
// 有匹配:生成高亮摘要(截取关键词附近内容)
const fullText = postContent || postDescription || postTitle; // 优先用内容,无则用描述/标题
// 找到第一个关键词出现的位置(取最小索引)
const firstOccur = keywords.reduce((acc, word) => {
const index = fullText.indexOf(word);
return index !== -1 && index < acc ? index : acc; // 存在且索引更小则更新
}, fullText.length);
// 截取摘要范围:关键词前40字符到后120字符(防止超出文本长度)
const start = Math.max(firstOccur - 40, 0);
const end = Math.min(firstOccur + 120, fullText.length);
// 移除HTML标签,高亮关键词(用mark标签包裹)
const matchContent = this.stripHTML(fullText).slice(start, end)
.replace(new RegExp(`(${keywords.join("|")})`, "gi"), "<mark>$1</mark>");
// 将高亮摘要赋值给post.digest,供后续渲染使用
post.digest = `${matchContent}......`;
return true; // 匹配成功,返回true
}
/**
* 构建单个文章/页面结果项的HTML
* 包含:头图、标题(高亮关键词)、日期、分类、标签、摘要
* @param {Object} item - 单个文章/页面对象
* @param {string} type - 结果类型(post:文章,page:页面)
* @returns {string} 单个结果项的HTML字符串
*/
buildResult(item, type) {
// 处理结果链接:取URL的pathname部分(去除查询参数)
const resultUrl = new URL(item.permalink).pathname.split("?")[0];
// 格式化日期:优先用更新时间,无则用创建时间,转为YYYY-MM-DD格式
const date = new Date(item.updated || item.date);
const formattedDate = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
// 构建标签HTML(若有标签)
let tagsHtml = '';
if (item.tags && item.tags.length > 0) {
tagsHtml = `<div class="tags">
${item.tags.map(tag =>
`<a href="${tag.permalink}" class="tag">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" stroke="currentColor">
<line x1="4" y1="9" x2="20" y2="9"></line><line x1="4" y1="15" x2="20" y2="15"></line><line x1="10" y1="3" x2="8" y2="21"></line><line x1="16" y1="3" x2="14" y2="21"></line>
</svg>
${this.highlightMatch(tag.name)}
</a>` // 标签名高亮关键词
).join('')}
</div>`;
}
// 构建分类HTML(若有分类)
let categoryHtml = '';
if (item.categories && item.categories.length > 0) {
categoryHtml = `<div class="category">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
${item.categories.map(cat => `<a href="${cat.permalink}">${cat.name}</a>`).join('')}
</div>`;
}
// 构建头图HTML(若有头图,仅文章可能有)
let headimgHtml = '';
if (item.headimg) {
headimgHtml = `<div class="result-image">
<picture>
<img src="${item.headimg}" alt="${item.title}">
</picture>
</div>`;
}
// 拼接单个结果项HTML(包含类型标识、链接、头图、内容区)
return `
<li class="result-item ${type}-result">
<a class="result-link" href="${resultUrl}?keyword=${encodeURIComponent(this.queryText)}">
${headimgHtml}
<div class="result-content">
<h3 class="title">${this.highlightMatch(item.title)}</h3>
<div class="meta">
<span class="date">${formattedDate}</span>
${categoryHtml}
</div>
${tagsHtml}
${item.digest ? `<p class="digest">${item.digest}</p>` : ''}
</div>
</a>
</li>
`;
}
/**
* 高亮文本中匹配的关键词
* 用mark标签包裹关键词,支持多个关键词(不区分大小写)
* @param {string} text - 待处理的文本(标题/标签名等)
* @returns {string} 关键词高亮后的HTML文本
*/
highlightMatch(text) {
// 关键词为空时,返回原文本
if (!this.queryText) return text;
// 拆分关键词为数组(支持空格/连字符分隔)
const keywords = this.queryText.toLowerCase().split(/[-\s]+/);
// 无关键词时,返回原文本
if (!keywords.length) return text;
// 创建正则表达式(全局匹配、不区分大小写)
const regex = new RegExp(`(${keywords.join("|")})`, "gi");
// 用mark标签包裹匹配的关键词,返回处理后的文本
return text.replace(regex, '<mark>$1</mark>');
}
/**
* 移除HTML标签和多余格式,返回纯文本
* 用于生成摘要时清理HTML内容,避免标签干扰
* @param {string} html - 带HTML标签的文本
* @returns {string} 纯文本字符串
*/
stripHTML(html) {
return html
.replace(/\n+/g, ' ') // 替换所有换行符为空格
.replace(/<(img|figure)[^>]*>.*?<\/\1>/g, '') // 移除img和figure标签及其内容
.replace(/<[^>]+>/g, '') // 移除所有剩余HTML标签
.trim() // 去除前后空格
.replace(/\s+/g, ' ') // 合并多个空格为单个空格
.replace(/(\S)(https:\/\/)/g, '$1 $2'); // 在链接前加空格(避免链接与前面文本粘连)
}
/**
* 添加搜索历史到历史列表
* 去重(不区分大小写)、限制最大数量、存储到localStorage、更新显示
* @param {string} query - 待添加的搜索关键词
*/
addToSearchHistory(query) {
// 去重:将新关键词放在最前,过滤掉相同关键词(不区分大小写),限制最大数量
this.searchHistory = [
query, // 新关键词放在首位(最新)
...this.searchHistory.filter(item => item.toLowerCase() !== query.toLowerCase()) // 过滤相同关键词
].slice(0, this.maxHistoryCount); // 截取前maxHistoryCount个(限制数量)
// 存储到localStorage(持久化,刷新页面不丢失)
localStorage.setItem('searchHistory', JSON.stringify(this.searchHistory));
// 更新搜索历史的DOM显示
this.updateHistoryDisplay();
}
/**
* 从localStorage加载搜索历史
* 处理JSON解析错误,避免程序崩溃
*/
loadSearchHistory() {
// 从localStorage获取存储的历史记录(JSON字符串)
const saved = localStorage.getItem('searchHistory');
if (saved) {
try {
// 解析JSON字符串为数组,赋值给searchHistory
this.searchHistory = JSON.parse(saved);
} catch (e) {
// 解析错误时(如数据格式异常),打印日志并重置历史数组
console.error('Failed to parse search history from localStorage:', e);
this.searchHistory = [];
}
}
}
/**
* 更新搜索历史的DOM显示
* 无历史时显示“暂无搜索历史”,有历史时生成列表项(带删除按钮)
*/
updateHistoryDisplay() {
const historyContainer = document.querySelector('.search-history');
const historyList = document.querySelector('.history-list');
// 若历史列表DOM不存在,直接返回
if (!historyList) return;
// 无搜索历史时,显示提示文本
if (this.searchHistory.length === 0) {
historyList.innerHTML = `<p class="no-history">${this.noSearchHistoryText}</p>`;
historyContainer.classList.add('hidden'); // 隐藏历史区
return;
}
// 有搜索历史:生成列表项HTML(每个项带删除按钮)
historyList.innerHTML = this.searchHistory.map((item, index) =>
`<li>${item}
<button class="remove-history" data-index="${index}">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</li>`
).join('');
// 绑定单个历史记录的删除事件
document.querySelectorAll('.remove-history').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const index = parseInt(e.target.closest('.remove-history').dataset.index);
this.searchHistory.splice(index, 1);
localStorage.setItem('searchHistory', JSON.stringify(this.searchHistory));
this.updateHistoryDisplay();
});
});
}
/**
* 清空所有搜索历史
* 重置历史数组、删除localStorage中的记录、更新显示
*/
clearSearchHistory() {
this.searchHistory = []; // 重置历史数组
localStorage.removeItem('searchHistory'); // 删除localStorage中的记录
this.updateHistoryDisplay(); // 更新历史显示(显示“暂无搜索历史”)
}
/**
* 防抖函数
*/
debounce(func, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args); // 透传this和参数
}, delay);
};
}
/**
* 静态方法:设置搜索关键词
* 初始化单例后,赋值查询关键词(供外部调用)
* @param {string} queryText - 待设置的搜索关键词
*/
static setQueryText(queryText) {
SearchService.init(); // 确保单例已初始化
SearchService.instance.queryText = queryText; // 赋值关键词
}
/**
* 静态方法:执行搜索
* 初始化单例后,调用实例的search方法(供外部调用)
* @returns {Promise<void>} 搜索异步过程
*/
static async search() {
SearchService.init(); // 确保单例已初始化
await SearchService.instance.search(); // 调用实例的搜索方法
}
}
/**
* 初始化加载
*/
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new SearchService());
} else {
new SearchService();
}
volantis.pjax.push(() => new SearchService(), 'hexo-search');
volantis.pjax.send(() => {
const searchEl = document.querySelector("#u-search");
if (searchEl) searchEl.style.display = "none";
}, 'hexo-search');