sync from local backup

This commit is contained in:
chunyu
2026-08-05 23:59:22 +08:00
commit a59f46b894
409 changed files with 166027 additions and 0 deletions
+465
View File
@@ -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;