Files
chunyu_prject_react/src/pages/Search/Search.tsx
T
chunyu af18085f40 feat(C-04):AI图片放大页+搜索结果跳转修复
新增/utility/image-upscaler页(2x/4x上传放大、积分扣费402余额不足分支、blob下载);Search页响应体剥层对齐(成功码10000)、q截断100、结果URL对齐后端现行路由(/post、/utility、/course-learn、/open-api-detail)。
2026-09-15 15:26:51 +08:00

472 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;