import React, { useState, useEffect, useCallback, useRef } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; import { Input, Tag, Empty, Spin, Radio, Image, Pagination } from "antd"; import { useTranslation } from "react-i18next"; import { SearchOutlined, EyeOutlined, FireOutlined, ClockCircleOutlined, StarOutlined, ApiOutlined, FileTextOutlined, ToolOutlined, BookOutlined, HistoryOutlined, DeleteOutlined, ArrowRightOutlined, CodeOutlined, CloudOutlined, ThunderboltOutlined, BulbOutlined, } from "@ant-design/icons"; import type { RadioChangeEvent } from "antd"; import { api_request } from "@/utils/request"; import "./Search.css"; interface SearchResult { id: number; type: string; title: string; desc: string; cover: string; views: number; likes: number; url: string; } const typeConfig: Record = { API: { color: "#667eea", label: "API接口", icon: }, 文章: { color: "#f5576c", label: "文章", icon: }, 工具: { color: "#43e97b", label: "工具", icon: }, 课程: { color: "#4facfe", label: "课程", icon: }, }; const sortOptions = [ { value: 1, label: <> {''}, key: 'relevance' }, { value: 2, label: <> {''}, key: 'views' }, { value: 3, label: <> {''}, key: 'newest' }, ]; const STORAGE_KEY = 'chunyu-search-history'; const MAX_HISTORY = 10; const DEBOUNCE_MS = 400; const getHistory = (): string[] => { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; } }; const addHistory = (keyword: string) => { const history = getHistory().filter(h => h !== keyword); history.unshift(keyword); localStorage.setItem(STORAGE_KEY, JSON.stringify(history.slice(0, MAX_HISTORY))); }; const clearHistory = () => { localStorage.removeItem(STORAGE_KEY); }; const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const highlightKeyword = (text: string, keyword: string) => { if (!keyword?.trim()) return text; const escaped = escapeRegExp(keyword.trim()); const regex = new RegExp(`(${escaped})`, 'gi'); const parts = text.split(regex); return parts.map((part, i) => regex.test(part) ? {part} : part ); }; const Search: React.FC = () => { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const query = searchParams.get("q") || ""; const [keyword, setKeyword] = useState(query); const [sort, setSort] = useState(1); const [activeFilter, setActiveFilter] = useState("all"); const [loading, setLoading] = useState(false); const [results, setResults] = useState([]); const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [hotKeywords, setHotKeywords] = useState([]); const [searchHistory, setSearchHistory] = useState(getHistory()); const [suggestions, setSuggestions] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const pageSize = 12; const debounceRef = useRef | null>(null); const suggestionsRef = useRef(null); useEffect(() => { const fetchHotKeywords = async () => { try { const res: any = await api_request.search.hotKeywords(); // axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000 const body = res?.data?.code !== undefined ? res.data : res; if (body?.code === 10000 && Array.isArray(body?.data)) { setHotKeywords(body.data); } } catch { setHotKeywords(["React Hooks", "RESTful API", "TypeScript", "Docker部署", "GraphQL", "微服务架构", "CI/CD", "性能优化"]); } }; fetchHotKeywords(); setSearchHistory(getHistory()); }, []); const doSearch = useCallback(async (q: string, pageNum: number = 1, searchFilter?: string, searchSort?: number) => { if (!q.trim()) { setResults([]); setTotal(0); return; } setLoading(true); try { const filterToUse = searchFilter !== undefined ? searchFilter : activeFilter; const sortToUse = searchSort !== undefined ? searchSort : sort; const res: any = await api_request.search.global({ q: q.trim().slice(0, 100), type: filterToUse === 'all' ? undefined : filterToUse, page: pageNum, page_size: pageSize, sort: sortToUse, }); // axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000 const body = res?.data?.code !== undefined ? res.data : res; if (body?.code === 10000 && body?.data) { setResults(body.data.results || []); setTotal(body.data.total || 0); setCurrentPage(pageNum); } } catch { setResults([]); setTotal(0); } finally { setLoading(false); } }, [activeFilter, sort]); useEffect(() => { setKeyword(query); setCurrentPage(1); if (debounceRef.current) clearTimeout(debounceRef.current); if (query) { debounceRef.current = setTimeout(() => { doSearch(query, 1); }, DEBOUNCE_MS); } else { setResults([]); setTotal(0); } return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [query, doSearch]); useEffect(() => { if (query) { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { doSearch(query, currentPage); }, DEBOUNCE_MS); } return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [activeFilter, sort]); useEffect(() => { const fetchSuggestions = async () => { if (keyword.trim().length < 1) { setSuggestions([]); return; } try { const res: any = await api_request.search.suggestions({ q: keyword.trim(), limit: 8 }); // axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000 const body = res?.data?.code !== undefined ? res.data : res; if (body?.code === 10000) { setSuggestions(body?.data || []); } } catch { setSuggestions([]); } }; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(fetchSuggestions, 200); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [keyword]); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (suggestionsRef.current && !suggestionsRef.current.contains(e.target as Node)) { setShowSuggestions(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const onSearch = (value: string) => { const trimmed = value.trim(); if (trimmed) { addHistory(trimmed); setSearchHistory(getHistory()); setSearchParams({ q: trimmed }); setActiveFilter("all"); } }; const onSortChange = (e: RadioChangeEvent) => { setSort(e.target.value); }; const onPageChange = (page: number) => { setCurrentPage(page); doSearch(query, page); }; const handleClearHistory = () => { clearHistory(); setSearchHistory([]); }; const getResultUrl = (item: SearchResult) => { if (item.url) return item.url; if (item.type === '文章') return `/post/${item.id}`; if (item.type === '工具') return '/utility'; if (item.type === '课程') return `/course-learn?id=${item.id}`; if (item.type === 'API') return `/open-api-detail/${item.id}`; return '/search'; }; const categories = [ { name: t('search.typeApi'), icon: , gradient: "linear-gradient(135deg, #667eea, #764ba2)", path: "/open-api" }, { name: t('search.typeArticle'), icon: , gradient: "linear-gradient(135deg, #f093fb, #f5576c)", path: "/articles" }, { name: t('search.typeCourse'), icon: , gradient: "linear-gradient(135deg, #4facfe, #00f2fe)", path: "/learn" }, { name: t('search.typeTool'), icon: , gradient: "linear-gradient(135deg, #43e97b, #38f9d7)", path: "/utility" }, { name: t('search.typeFrontend'), icon: , gradient: "linear-gradient(135deg, #fa709a, #fee140)", path: "/open-api" }, { name: t('search.typeBackend'), icon: , gradient: "linear-gradient(135deg, #a18cd1, #fbc2eb)", path: "/learn" }, ]; const filterTabs = [ { key: "all", label: t('search.filterAll'), icon: null }, { key: "article", label: t('search.filterArticle'), icon: }, { key: "tool", label: t('search.filterTool'), icon: }, { key: "course", label: t('search.filterCourse'), icon: }, { key: "api", label: t('search.filterApi'), icon: }, ]; const renderLanding = () => (

{t('search.historyTitle')}

{searchHistory.length > 0 && ( {t('search.clearHistory')} )}
{searchHistory.map((item) => ( onSearch(item)}> {item} ))}

{t('search.categoriesTitle')}

{categories.map((cat) => (
navigate(cat.path)} >
{cat.icon}
{cat.name}
))}

{t('search.hotRecommendations')}

{hotKeywords.slice(0, 8).map((kw, index) => (
onSearch(kw)} style={{ animationDelay: `${index * 0.1}s` }} >
{t('search.hotLabel').replace(':', '')}
{kw}

{t('search.heroSubtitle')}

{Math.floor(Math.random() * 5000 + 1000)} {Math.floor(Math.random() * 500 + 50)}
))}
); const renderResults = () => (
{t('search.resultCount', { keyword: query, count: total })} ({ ...opt, label: opt.label.props.children[1] || '', }))} />
{filterTabs.map((tab) => (
{ setActiveFilter(tab.key); setCurrentPage(1); }} > {tab.icon} {tab.label}
))}
{loading ? (
) : total === 0 ? ( ) : ( <>
{results.map((item) => (
navigate(getResultUrl(item))}>
{item.title} {typeConfig[item.type]?.icon || } {typeConfig[item.type]?.label || item.type}
{highlightKeyword(item.title, query)}

{highlightKeyword(item.desc, query)}

{item.views} {item.likes}
))}
t('search.totalItems', { count: totalCount })} onChange={onPageChange} />
)}
); return (
{Array.from({ length: 20 }).map((_, i) => ( ))}

{t('search.heroTitle')}

{t('search.heroSubtitle')}

} value={keyword} onChange={(e) => { setKeyword(e.target.value); setShowSuggestions(true); }} onPressEnter={(e) => { onSearch((e.target as HTMLInputElement).value); setShowSuggestions(false); }} onFocus={() => setShowSuggestions(true)} className="search-hero-input" allowClear /> {showSuggestions && suggestions.length > 0 && (
{suggestions.map((s) => (
{ setKeyword(s); onSearch(s); setShowSuggestions(false); }} > {highlightKeyword(s, keyword)}
))}
)}
{t('search.hotLabel')} {hotKeywords.map((kw) => ( onSearch(kw)}> {kw} ))}
{query ? renderResults() : renderLanding()}
); }; export default Search;