新增/utility/image-upscaler页(2x/4x上传放大、积分扣费402余额不足分支、blob下载);Search页响应体剥层对齐(成功码10000)、q截断100、结果URL对齐后端现行路由(/post、/utility、/course-learn、/open-api-detail)。
472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
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: 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: <ApiOutlined />, gradient: "linear-gradient(135deg, #667eea, #764ba2)", path: "/open-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: "/learn" },
|
||
{ name: t('search.typeTool'), icon: <ToolOutlined />, gradient: "linear-gradient(135deg, #43e97b, #38f9d7)", path: "/utility" },
|
||
{ name: t('search.typeFrontend'), icon: <CodeOutlined />, gradient: "linear-gradient(135deg, #fa709a, #fee140)", path: "/open-api" },
|
||
{ name: t('search.typeBackend'), icon: <CloudOutlined />, 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: <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; |