sync from local backup
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
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<string, { color: string; label: string; icon: React.ReactNode }> = {
|
||||
API: { color: "#667eea", label: "API接口", icon: <ApiOutlined /> },
|
||||
文章: { color: "#f5576c", label: "文章", icon: <FileTextOutlined /> },
|
||||
工具: { color: "#43e97b", label: "工具", icon: <ToolOutlined /> },
|
||||
课程: { color: "#4facfe", label: "课程", icon: <BookOutlined /> },
|
||||
};
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 1, label: <><StarOutlined /> {''}</>, key: 'relevance' },
|
||||
{ value: 2, label: <><FireOutlined /> {''}</>, key: 'views' },
|
||||
{ value: 3, label: <><ClockCircleOutlined /> {''}</>, 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)
|
||||
? <mark key={i} className="search-highlight">{part}</mark>
|
||||
: 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<SearchResult[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hotKeywords, setHotKeywords] = useState<string[]>([]);
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>(getHistory());
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const pageSize = 12;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHotKeywords = async () => {
|
||||
try {
|
||||
const res = await api_request.search.hotKeywords();
|
||||
if (res.data?.code === 0) {
|
||||
setHotKeywords(res.data.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 = await api_request.search.global({
|
||||
q: q.trim(),
|
||||
type: filterToUse === 'all' ? undefined : filterToUse,
|
||||
page: pageNum,
|
||||
page_size: pageSize,
|
||||
sort: sortToUse,
|
||||
});
|
||||
if (res.data?.code === 0) {
|
||||
setResults(res.data.data.results || []);
|
||||
setTotal(res.data.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 = await api_request.search.suggestions({ q: keyword.trim(), limit: 8 });
|
||||
if (res.data?.code === 0) {
|
||||
setSuggestions(res.data.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 `/articles/${item.id}`;
|
||||
if (item.type === '工具') return `/tools/${item.id}`;
|
||||
if (item.type === '课程') return `/courses/${item.id}`;
|
||||
return '/search';
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ name: t('search.typeApi'), icon: <ApiOutlined />, gradient: "linear-gradient(135deg, #667eea, #764ba2)", path: "/api" },
|
||||
{ name: t('search.typeArticle'), icon: <FileTextOutlined />, gradient: "linear-gradient(135deg, #f093fb, #f5576c)", path: "/articles" },
|
||||
{ name: t('search.typeCourse'), icon: <BookOutlined />, gradient: "linear-gradient(135deg, #4facfe, #00f2fe)", path: "/courses" },
|
||||
{ name: t('search.typeTool'), icon: <ToolOutlined />, gradient: "linear-gradient(135deg, #43e97b, #38f9d7)", path: "/api" },
|
||||
{ name: t('search.typeFrontend'), icon: <CodeOutlined />, gradient: "linear-gradient(135deg, #fa709a, #fee140)", path: "/frontend" },
|
||||
{ name: t('search.typeBackend'), icon: <CloudOutlined />, gradient: "linear-gradient(135deg, #a18cd1, #fbc2eb)", path: "/backend" },
|
||||
];
|
||||
|
||||
const filterTabs = [
|
||||
{ key: "all", label: t('search.filterAll'), icon: null },
|
||||
{ key: "article", label: t('search.filterArticle'), icon: <FileTextOutlined /> },
|
||||
{ key: "tool", label: t('search.filterTool'), icon: <ToolOutlined /> },
|
||||
{ key: "course", label: t('search.filterCourse'), icon: <BookOutlined /> },
|
||||
{ key: "api", label: t('search.filterApi'), icon: <ApiOutlined /> },
|
||||
];
|
||||
|
||||
const renderLanding = () => (
|
||||
<div className="search-landing">
|
||||
<div className="search-landing-inner">
|
||||
<div className="search-history-section">
|
||||
<div className="search-section-header">
|
||||
<h3><HistoryOutlined /> {t('search.historyTitle')}</h3>
|
||||
{searchHistory.length > 0 && (
|
||||
<span className="search-clear-history" onClick={handleClearHistory}>
|
||||
<DeleteOutlined /> {t('search.clearHistory')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="search-history-list">
|
||||
{searchHistory.map((item) => (
|
||||
<Tag key={item} className="search-history-tag" onClick={() => onSearch(item)}>
|
||||
<ClockCircleOutlined /> {item}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-categories-section">
|
||||
<h3 className="search-section-title"><BulbOutlined /> {t('search.categoriesTitle')}</h3>
|
||||
<div className="search-categories-grid">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.name}
|
||||
className="search-category-card"
|
||||
style={{ background: cat.gradient }}
|
||||
onClick={() => navigate(cat.path)}
|
||||
>
|
||||
<div className="search-category-icon">{cat.icon}</div>
|
||||
<div className="search-category-name">{cat.name}</div>
|
||||
<ArrowRightOutlined className="search-category-arrow" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-hot-section">
|
||||
<h3 className="search-section-title"><ThunderboltOutlined /> {t('search.hotRecommendations')}</h3>
|
||||
<div className="search-hot-grid">
|
||||
{hotKeywords.slice(0, 8).map((kw, index) => (
|
||||
<div
|
||||
key={kw}
|
||||
className="search-hot-card"
|
||||
onClick={() => onSearch(kw)}
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="search-hot-cover">
|
||||
<Image
|
||||
src={`https://images.unsplash.com/photo-${1555066931 + index}?w=400&h=250&fit=crop`}
|
||||
width="100%"
|
||||
height={120}
|
||||
preview={false}
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
<span className="search-type-badge" style={{ background: '#667eea' }}>
|
||||
<FireOutlined style={{ fontSize: 10 }} /> {t('search.hotLabel').replace(':', '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="search-hot-body">
|
||||
<div className="search-hot-title">{kw}</div>
|
||||
<p className="search-hot-desc">{t('search.heroSubtitle')}</p>
|
||||
<div className="search-hot-stats">
|
||||
<span><EyeOutlined /> {Math.floor(Math.random() * 5000 + 1000)}</span>
|
||||
<span><StarOutlined /> {Math.floor(Math.random() * 500 + 50)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderResults = () => (
|
||||
<div className="search-content">
|
||||
<div className="search-toolbar">
|
||||
<span className="search-result-count">
|
||||
{t('search.resultCount', { keyword: query, count: total })}
|
||||
</span>
|
||||
<Radio.Group
|
||||
onChange={onSortChange}
|
||||
value={sort}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
options={sortOptions.map(opt => ({
|
||||
...opt,
|
||||
label: opt.label.props.children[1] || '',
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="search-filter-tabs">
|
||||
{filterTabs.map((tab) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={`search-filter-tab ${activeFilter === tab.key ? "active" : ""}`}
|
||||
onClick={() => { setActiveFilter(tab.key); setCurrentPage(1); }}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="search-loading"><Spin size="large" /></div>
|
||||
) : total === 0 ? (
|
||||
<Empty description={t('search.emptyText')} className="search-empty" />
|
||||
) : (
|
||||
<>
|
||||
<div className="search-result-grid">
|
||||
{results.map((item) => (
|
||||
<div key={item.id} className="search-result-card" onClick={() => navigate(getResultUrl(item))}>
|
||||
<div className="search-result-cover">
|
||||
<Image
|
||||
height={160}
|
||||
width="100%"
|
||||
src={item.cover || 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=400&h=250&fit=crop'}
|
||||
preview={false}
|
||||
draggable={false}
|
||||
alt={item.title}
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
<span className="search-type-badge" style={{ background: typeConfig[item.type]?.color || '#667eea' }}>
|
||||
{typeConfig[item.type]?.icon || <FileTextOutlined />} {typeConfig[item.type]?.label || item.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="search-result-body">
|
||||
<div className="search-result-title">{highlightKeyword(item.title, query)}</div>
|
||||
<p className="search-result-desc">{highlightKeyword(item.desc, query)}</p>
|
||||
<div className="search-result-stats">
|
||||
<span><EyeOutlined /> {item.views}</span>
|
||||
<span><StarOutlined /> {item.likes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="search-pagination">
|
||||
<Pagination
|
||||
align="center"
|
||||
current={currentPage}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
showTotal={(totalCount) => t('search.totalItems', { count: totalCount })}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="search-page">
|
||||
<div className="search-hero">
|
||||
<div className="search-hero-particles">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<span key={i} className="search-particle" />
|
||||
))}
|
||||
</div>
|
||||
<div className="search-hero-glow" />
|
||||
<div className="search-hero-inner">
|
||||
<h1 className="search-hero-title">{t('search.heroTitle')}</h1>
|
||||
<p className="search-hero-subtitle">{t('search.heroSubtitle')}</p>
|
||||
<div className="search-hero-input-wrap" ref={suggestionsRef}>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder={t('search.inputPlaceholder')}
|
||||
prefix={<SearchOutlined style={{ color: "#667eea", fontSize: 20 }} />}
|
||||
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 && (
|
||||
<div className="search-suggestions-dropdown">
|
||||
{suggestions.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className="search-suggestion-item"
|
||||
onClick={() => { setKeyword(s); onSearch(s); setShowSuggestions(false); }}
|
||||
>
|
||||
<SearchOutlined className="search-suggestion-icon" />
|
||||
<span>{highlightKeyword(s, keyword)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="search-hot-keywords">
|
||||
<span className="search-hot-label"><FireOutlined /> {t('search.hotLabel')}</span>
|
||||
{hotKeywords.map((kw) => (
|
||||
<Tag key={kw} className="search-hot-tag" onClick={() => onSearch(kw)}>
|
||||
{kw}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query ? renderResults() : renderLanding()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
Reference in New Issue
Block a user