增加搜索框
This commit is contained in:
parent
8d6e7a3502
commit
e356212737
@ -25,6 +25,30 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
|
|
||||||
<!-- 导航链接 -->
|
<!-- 导航链接 -->
|
||||||
<div class="hidden md:flex md:items-center md:space-x-8">
|
<div class="hidden md:flex md:items-center md:space-x-8">
|
||||||
|
<!-- 桌面端搜索框 -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="desktop-search"
|
||||||
|
class="w-48 pl-10 pr-4 py-1.5 rounded-full text-sm text-gray-700 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 bg-gray-50/80 dark:bg-gray-800/60 border border-gray-200/60 dark:border-gray-700/40 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:focus:ring-primary-500 focus:bg-white dark:focus:bg-gray-800 focus:border-primary-300 dark:focus:border-primary-600 transition-all duration-300"
|
||||||
|
placeholder="搜索文章..."
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg class="h-4 w-4 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索结果容器(默认隐藏) -->
|
||||||
|
<div id="desktop-search-results" class="absolute top-full left-0 right-0 mt-2 max-h-80 overflow-y-auto rounded-lg bg-white/95 dark:bg-gray-800/95 shadow-md border border-gray-200/70 dark:border-gray-700/70 backdrop-blur-sm z-50 hidden">
|
||||||
|
<!-- 结果将通过JS动态填充 -->
|
||||||
|
<div class="p-4 text-center text-gray-500 dark:text-gray-400" id="desktop-search-message">
|
||||||
|
<p>输入关键词开始搜索</p>
|
||||||
|
</div>
|
||||||
|
<ul class="divide-y divide-gray-200/70 dark:divide-gray-700/70" id="desktop-search-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{NAV_LINKS.map(link => (
|
{NAV_LINKS.map(link => (
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@ -43,6 +67,19 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
|
|
||||||
<!-- 移动端菜单按钮 -->
|
<!-- 移动端菜单按钮 -->
|
||||||
<div class="flex items-center md:hidden">
|
<div class="flex items-center md:hidden">
|
||||||
|
<!-- 移动端搜索按钮 -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="mobile-search-button"
|
||||||
|
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 mr-2"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<span class="sr-only">搜索</span>
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
||||||
@ -61,6 +98,39 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端搜索面板 -->
|
||||||
|
<div id="mobile-search-panel" class="hidden md:hidden fixed inset-x-0 top-16 p-4 bg-white dark:bg-gray-800 shadow-md z-50 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="mobile-search"
|
||||||
|
class="w-full pl-10 pr-10 py-2 rounded-full text-sm text-gray-700 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 bg-gray-50/80 dark:bg-gray-800/60 border border-gray-200/60 dark:border-gray-700/40 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:focus:ring-primary-500 focus:bg-white dark:focus:bg-gray-800 focus:border-primary-300 dark:focus:border-primary-600 transition-all duration-300"
|
||||||
|
placeholder="搜索文章..."
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="mobile-search-close"
|
||||||
|
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- 移动端搜索结果 -->
|
||||||
|
<div id="mobile-search-results" class="mt-3 max-h-80 overflow-y-auto rounded-lg bg-white/95 dark:bg-gray-800/95 shadow-md border border-gray-200/70 dark:border-gray-700/70 backdrop-blur-sm hidden">
|
||||||
|
<!-- 结果将通过JS动态填充 -->
|
||||||
|
<div class="p-4 text-center text-gray-500 dark:text-gray-400" id="mobile-search-message">
|
||||||
|
<p>输入关键词开始搜索</p>
|
||||||
|
</div>
|
||||||
|
<ul class="divide-y divide-gray-200/70 dark:divide-gray-700/70" id="mobile-search-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 移动端菜单 -->
|
<!-- 移动端菜单 -->
|
||||||
<div class="hidden md:hidden fixed inset-x-0 top-16 z-40" id="mobile-menu">
|
<div class="hidden md:hidden fixed inset-x-0 top-16 z-40" id="mobile-menu">
|
||||||
<div id="mobile-menu-bg">
|
<div id="mobile-menu-bg">
|
||||||
@ -137,11 +207,26 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
background: rgba(15, 23, 42, 0.8);
|
background: rgba(15, 23, 42, 0.8);
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 搜索面板动画 */
|
||||||
|
#mobile-search-panel.show {
|
||||||
|
animation: slide-down 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const header = document.getElementById('header-bg');
|
const header = document.getElementById('header-bg');
|
||||||
const mobileMenuBg = document.getElementById('mobile-menu-bg');
|
|
||||||
const scrollThreshold = 50;
|
const scrollThreshold = 50;
|
||||||
|
|
||||||
function updateHeaderBackground() {
|
function updateHeaderBackground() {
|
||||||
@ -184,5 +269,206 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
menuCloseIcon.classList.toggle('hidden');
|
menuCloseIcon.classList.toggle('hidden');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 搜索功能逻辑
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 搜索节流函数
|
||||||
|
function debounce<T extends (...args: any[]) => void>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
return function(this: any, ...args: Parameters<T>): void {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取DOM元素
|
||||||
|
const desktopSearch = document.getElementById('desktop-search');
|
||||||
|
const desktopResults = document.getElementById('desktop-search-results');
|
||||||
|
const desktopList = document.getElementById('desktop-search-list');
|
||||||
|
const desktopMessage = document.getElementById('desktop-search-message');
|
||||||
|
|
||||||
|
const mobileSearchButton = document.getElementById('mobile-search-button');
|
||||||
|
const mobileSearchPanel = document.getElementById('mobile-search-panel');
|
||||||
|
const mobileSearch = document.getElementById('mobile-search');
|
||||||
|
const mobileResults = document.getElementById('mobile-search-results');
|
||||||
|
const mobileList = document.getElementById('mobile-search-list');
|
||||||
|
const mobileMessage = document.getElementById('mobile-search-message');
|
||||||
|
const mobileSearchClose = document.getElementById('mobile-search-close');
|
||||||
|
|
||||||
|
// 文章对象的接口定义
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string | Date;
|
||||||
|
summary?: string;
|
||||||
|
tags?: string[];
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let articles: Article[] = [];
|
||||||
|
|
||||||
|
// 获取文章数据
|
||||||
|
async function fetchArticles() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/search');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取文章数据失败');
|
||||||
|
}
|
||||||
|
articles = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取文章失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索文章
|
||||||
|
function searchArticles(query: string, resultsList: HTMLElement, resultsMessage: HTMLElement) {
|
||||||
|
if (!query.trim()) {
|
||||||
|
resultsList.innerHTML = '';
|
||||||
|
resultsMessage.textContent = '输入关键词开始搜索';
|
||||||
|
resultsMessage.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (articles.length === 0) {
|
||||||
|
resultsMessage.textContent = '正在加载数据...';
|
||||||
|
resultsMessage.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// 过滤并排序结果
|
||||||
|
const filteredArticles = articles
|
||||||
|
.filter(article => {
|
||||||
|
const title = article.title.toLowerCase();
|
||||||
|
const tags = article.tags ? article.tags.map(tag => tag.toLowerCase()) : [];
|
||||||
|
const summary = article.summary ? article.summary.toLowerCase() : '';
|
||||||
|
|
||||||
|
return title.includes(lowerQuery) ||
|
||||||
|
tags.some(tag => tag.includes(lowerQuery)) ||
|
||||||
|
summary.includes(lowerQuery);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// 标题匹配优先
|
||||||
|
const aTitle = a.title.toLowerCase();
|
||||||
|
const bTitle = b.title.toLowerCase();
|
||||||
|
|
||||||
|
if (aTitle.includes(lowerQuery) && !bTitle.includes(lowerQuery)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (!aTitle.includes(lowerQuery) && bTitle.includes(lowerQuery)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期排序
|
||||||
|
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||||
|
})
|
||||||
|
.slice(0, 10); // 限制结果数量
|
||||||
|
|
||||||
|
if (filteredArticles.length === 0) {
|
||||||
|
resultsList.innerHTML = '';
|
||||||
|
resultsMessage.textContent = '没有找到相关内容';
|
||||||
|
resultsMessage.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示结果
|
||||||
|
resultsMessage.style.display = 'none';
|
||||||
|
resultsList.innerHTML = filteredArticles.map(article => `
|
||||||
|
<li>
|
||||||
|
<a href="/articles/${article.id}" class="block px-4 py-3 hover:bg-gray-50/70 dark:hover:bg-gray-700/70 transition-colors duration-200">
|
||||||
|
<h3 class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">${article.title}</h3>
|
||||||
|
${article.summary ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">${article.summary}</p>` : ''}
|
||||||
|
${article.tags && article.tags.length > 0 ? `
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
${article.tags.slice(0, 3).map(tag => `
|
||||||
|
<span class="inline-block text-xs bg-primary-50/50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 py-0.5 px-1.5 rounded-full">#${tag}</span>
|
||||||
|
`).join('')}
|
||||||
|
${article.tags.length > 3 ? `<span class="text-xs text-gray-400 dark:text-gray-500">+${article.tags.length - 3}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节流搜索
|
||||||
|
const debouncedDesktopSearch = debounce((value: string) => {
|
||||||
|
if (desktopList && desktopMessage) {
|
||||||
|
searchArticles(value, desktopList as HTMLElement, desktopMessage as HTMLElement);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const debouncedMobileSearch = debounce((value: string) => {
|
||||||
|
if (mobileList && mobileMessage) {
|
||||||
|
searchArticles(value, mobileList as HTMLElement, mobileMessage as HTMLElement);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// 桌面端搜索逻辑
|
||||||
|
if (desktopSearch && desktopResults) {
|
||||||
|
desktopSearch.addEventListener('focus', () => {
|
||||||
|
desktopResults.classList.remove('hidden');
|
||||||
|
if (!articles.length) fetchArticles();
|
||||||
|
});
|
||||||
|
|
||||||
|
desktopSearch.addEventListener('input', (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (target && target.value !== undefined) {
|
||||||
|
debouncedDesktopSearch(target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击外部关闭结果
|
||||||
|
document.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (desktopSearch && !desktopSearch.contains(target) && !desktopResults.contains(target)) {
|
||||||
|
desktopResults.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC键关闭结果
|
||||||
|
desktopSearch.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
desktopResults.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端搜索逻辑
|
||||||
|
if (mobileSearchButton && mobileSearchPanel) {
|
||||||
|
mobileSearchButton.addEventListener('click', () => {
|
||||||
|
mobileSearchPanel.classList.remove('hidden');
|
||||||
|
mobileSearchPanel.classList.add('show');
|
||||||
|
if (mobileSearch) mobileSearch.focus();
|
||||||
|
if (!articles.length) fetchArticles();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mobileSearchClose) {
|
||||||
|
mobileSearchClose.addEventListener('click', () => {
|
||||||
|
mobileSearchPanel.classList.add('hidden');
|
||||||
|
mobileSearchPanel.classList.remove('show');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileSearch && mobileResults) {
|
||||||
|
mobileSearch.addEventListener('input', (e: Event) => {
|
||||||
|
mobileResults.classList.remove('hidden');
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (target && target.value !== undefined) {
|
||||||
|
debouncedMobileSearch(target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC键关闭搜索面板
|
||||||
|
mobileSearch.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
mobileSearchPanel.classList.add('hidden');
|
||||||
|
mobileSearchPanel.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
38
src/pages/api/search.ts
Normal file
38
src/pages/api/search.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// 获取所有文章
|
||||||
|
const articles = await getCollection('articles');
|
||||||
|
|
||||||
|
// 过滤掉草稿文章,并转换为简化的数据结构
|
||||||
|
const formattedArticles = articles
|
||||||
|
.filter(article => !article.data.draft) // 过滤掉草稿
|
||||||
|
.map(article => ({
|
||||||
|
id: article.id,
|
||||||
|
title: article.data.title,
|
||||||
|
date: article.data.date,
|
||||||
|
summary: article.data.summary || '',
|
||||||
|
tags: article.data.tags || [],
|
||||||
|
image: article.data.image || ''
|
||||||
|
}))
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // 按日期排序
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(formattedArticles), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// 添加缓存头,缓存1小时
|
||||||
|
'Cache-Control': 'public, max-age=3600'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取文章数据失败:', error);
|
||||||
|
return new Response(JSON.stringify({ error: '获取文章数据失败' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user