|
|
@@ -0,0 +1,447 @@
|
|
|
+import React, { useState, useEffect } from 'react';
|
|
|
+import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
|
+import { useQuery } from '@tanstack/react-query';
|
|
|
+import { MagnifyingGlassIcon, XMarkIcon, ClockIcon, MapPinIcon, CalendarDaysIcon } from '@heroicons/react/24/outline';
|
|
|
+import { SkeletonLoader } from '../components/SkeletonLoader';
|
|
|
+import { useAuth } from '../hooks/AuthProvider';
|
|
|
+import { useHomeData } from '../hooks/useHomeData';
|
|
|
+import { homeClient } from '@/client/api';
|
|
|
+import { unsplash } from '../utils/unsplash';
|
|
|
+
|
|
|
+// 中国水墨风格色彩方案
|
|
|
+const COLORS = {
|
|
|
+ ink: {
|
|
|
+ light: '#f5f3f0',
|
|
|
+ medium: '#d4c4a8',
|
|
|
+ dark: '#8b7355',
|
|
|
+ deep: '#3a2f26',
|
|
|
+ },
|
|
|
+ accent: {
|
|
|
+ red: '#a85c5c',
|
|
|
+ blue: '#4a6b7c',
|
|
|
+ green: '#5c7c5c',
|
|
|
+ },
|
|
|
+ text: {
|
|
|
+ primary: '#2f1f0f',
|
|
|
+ secondary: '#5d4e3b',
|
|
|
+ light: '#8b7355',
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 字体样式
|
|
|
+const FONT_STYLES = {
|
|
|
+ title: 'font-serif text-2xl font-bold tracking-wide',
|
|
|
+ sectionTitle: 'font-serif text-xl font-semibold tracking-wide',
|
|
|
+ body: 'font-sans text-base leading-relaxed',
|
|
|
+ caption: 'font-sans text-sm',
|
|
|
+ small: 'font-sans text-xs',
|
|
|
+};
|
|
|
+
|
|
|
+// 格式化薪资
|
|
|
+const formatSalary = (min: number, max: number): string => {
|
|
|
+ if (min === 0 && max === 0) return '面议';
|
|
|
+ if (min === max) return `¥${min.toLocaleString()}/月`;
|
|
|
+ return `¥${min.toLocaleString()}-${max.toLocaleString()}/月`;
|
|
|
+};
|
|
|
+
|
|
|
+// 格式化时间
|
|
|
+const formatTimeAgo = (date: string): string => {
|
|
|
+ const publishDate = new Date(date);
|
|
|
+ const now = new Date();
|
|
|
+ const diffDays = Math.floor((now.getTime() - publishDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
+
|
|
|
+ if (diffDays === 0) return '今天';
|
|
|
+ if (diffDays === 1) return '昨天';
|
|
|
+ if (diffDays < 7) return `${diffDays}天前`;
|
|
|
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前`;
|
|
|
+ return `${Math.floor(diffDays / 30)}月前`;
|
|
|
+};
|
|
|
+
|
|
|
+// 搜索客户端 - 使用已配置的homeClient
|
|
|
+const searchClient = {
|
|
|
+ search: async (keyword: string, type: string = 'all', limit: number = 20) => {
|
|
|
+ const response = await homeClient.search.$get({
|
|
|
+ query: {
|
|
|
+ keyword,
|
|
|
+ type,
|
|
|
+ limit: limit.toString()
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error('搜索失败');
|
|
|
+ }
|
|
|
+ return response.json();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const SearchPage: React.FC = () => {
|
|
|
+ const [searchParams, setSearchParams] = useSearchParams();
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const { user } = useAuth();
|
|
|
+
|
|
|
+ const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
|
|
+ const [searchType, setSearchType] = useState<'all' | 'jobs' | 'knowledge' | 'companies'>('all');
|
|
|
+ const [isSearching, setIsSearching] = useState(false);
|
|
|
+
|
|
|
+ // 搜索历史
|
|
|
+ const [searchHistory, setSearchHistory] = useState<string[]>(() => {
|
|
|
+ const saved = localStorage.getItem('searchHistory');
|
|
|
+ return saved ? JSON.parse(saved) : [];
|
|
|
+ });
|
|
|
+
|
|
|
+ // 热门搜索
|
|
|
+ const hotSearches = [
|
|
|
+ '教育培训', '医疗护理', '家政服务', '社区工作',
|
|
|
+ '银龄岗位', '志愿服务', '时间银行', '老年大学'
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 搜索查询
|
|
|
+ const { data: searchResults, isLoading, isError, error, refetch } = useQuery({
|
|
|
+ queryKey: ['search', searchQuery, searchType],
|
|
|
+ queryFn: async () => {
|
|
|
+ if (!searchQuery.trim()) return null;
|
|
|
+ return await searchClient.search(searchQuery, searchType, 20);
|
|
|
+ },
|
|
|
+ enabled: !!searchQuery.trim(),
|
|
|
+ staleTime: 5 * 60 * 1000, // 5分钟缓存
|
|
|
+ });
|
|
|
+
|
|
|
+ // 保存搜索历史
|
|
|
+ const saveSearchHistory = (query: string) => {
|
|
|
+ if (!query.trim()) return;
|
|
|
+
|
|
|
+ const newHistory = [query, ...searchHistory.filter(item => item !== query)].slice(0, 10);
|
|
|
+ setSearchHistory(newHistory);
|
|
|
+ localStorage.setItem('searchHistory', JSON.stringify(newHistory));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 执行搜索
|
|
|
+ const handleSearch = (query?: string) => {
|
|
|
+ const searchTerm = query || searchQuery;
|
|
|
+ if (!searchTerm.trim()) return;
|
|
|
+
|
|
|
+ setSearchQuery(searchTerm);
|
|
|
+ setSearchParams({ q: searchTerm, type: searchType });
|
|
|
+ saveSearchHistory(searchTerm);
|
|
|
+ setIsSearching(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 清除搜索历史
|
|
|
+ const clearSearchHistory = () => {
|
|
|
+ setSearchHistory([]);
|
|
|
+ localStorage.removeItem('searchHistory');
|
|
|
+ };
|
|
|
+
|
|
|
+ // 清除单个搜索历史
|
|
|
+ const removeSearchHistoryItem = (item: string) => {
|
|
|
+ const newHistory = searchHistory.filter(h => h !== item);
|
|
|
+ setSearchHistory(newHistory);
|
|
|
+ localStorage.setItem('searchHistory', JSON.stringify(newHistory));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 键盘事件处理
|
|
|
+ const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
|
+ if (e.key === 'Enter') {
|
|
|
+ handleSearch();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理类型切换
|
|
|
+ const handleTypeChange = (type: 'all' | 'jobs' | 'knowledge' | 'companies') => {
|
|
|
+ setSearchType(type);
|
|
|
+ setSearchParams({ q: searchQuery, type });
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染搜索结果数量
|
|
|
+ const getResultCount = () => {
|
|
|
+ if (!searchResults) return 0;
|
|
|
+
|
|
|
+ let count = 0;
|
|
|
+ if (searchResults.jobs) count += searchResults.jobs.length;
|
|
|
+ if (searchResults.knowledge) count += searchResults.knowledge.length;
|
|
|
+ if (searchResults.companies) count += searchResults.companies.length;
|
|
|
+
|
|
|
+ return count;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染搜索结果卡片
|
|
|
+ const renderJobCard = (job: any) => (
|
|
|
+ <div
|
|
|
+ key={job.id}
|
|
|
+ className="bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 p-4 mb-3 cursor-pointer"
|
|
|
+ onClick={() => navigate(`/silver-jobs/${job.id}`)}
|
|
|
+ >
|
|
|
+ <div className="flex items-start space-x-3">
|
|
|
+ <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0">
|
|
|
+ {job.company?.logo ? (
|
|
|
+ <img src={job.company.logo} alt={job.company.name} className="w-full h-full rounded-full object-cover" />
|
|
|
+ ) : (
|
|
|
+ <span className="text-lg font-bold" style={{ color: COLORS.ink.dark }}>
|
|
|
+ {job.company?.name?.slice(0, 2) || '岗位'}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="flex-1">
|
|
|
+ <h3 className="font-medium text-lg mb-1" style={{ color: COLORS.text.primary }}>
|
|
|
+ {job.title}
|
|
|
+ </h3>
|
|
|
+ <p className="text-sm mb-2" style={{ color: COLORS.text.secondary }}>
|
|
|
+ {job.company?.name || '未知公司'}
|
|
|
+ </p>
|
|
|
+ <div className="flex items-center space-x-2 text-xs">
|
|
|
+ <span className={`px-2 py-1 rounded-full ${COLORS.accent.blue} text-white`}>
|
|
|
+ {formatSalary(job.salaryMin || 0, job.salaryMax || 0)}
|
|
|
+ </span>
|
|
|
+ <span className="flex items-center text-gray-600">
|
|
|
+ <MapPinIcon className="w-3 h-3 mr-1" />
|
|
|
+ {job.location || '全国'}
|
|
|
+ </span>
|
|
|
+ <span className="text-gray-500">
|
|
|
+ {formatTimeAgo(job.createdAt)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ const renderKnowledgeCard = (knowledge: any) => (
|
|
|
+ <div
|
|
|
+ key={knowledge.id}
|
|
|
+ className="bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 p-4 mb-3 cursor-pointer"
|
|
|
+ onClick={() => navigate(`/silver-wisdom/${knowledge.id}`)}
|
|
|
+ >
|
|
|
+ <div className="flex items-start space-x-3">
|
|
|
+ <div className="w-16 h-16 rounded-lg bg-gray-100 flex-shrink-0">
|
|
|
+ <img
|
|
|
+ src={knowledge.coverImage || unsplash.getEducationImage(knowledge.id)}
|
|
|
+ alt={knowledge.title}
|
|
|
+ className="w-full h-full rounded-lg object-cover"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="flex-1">
|
|
|
+ <h3 className="font-medium text-lg mb-1" style={{ color: COLORS.text.primary }}>
|
|
|
+ {knowledge.title}
|
|
|
+ </h3>
|
|
|
+ <p className="text-sm mb-2" style={{ color: COLORS.text.secondary }}>
|
|
|
+ {knowledge.category?.name || '其他分类'}
|
|
|
+ </p>
|
|
|
+ <div className="flex items-center space-x-2 text-xs text-gray-500">
|
|
|
+ <span>阅读 {knowledge.viewCount || 0}</span>
|
|
|
+ <span>·</span>
|
|
|
+ <span>{formatTimeAgo(knowledge.createdAt)}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ const renderCompanyCard = (company: any) => (
|
|
|
+ <div
|
|
|
+ key={company.id}
|
|
|
+ className="bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 p-4 mb-3 cursor-pointer"
|
|
|
+ onClick={() => navigate(`/company/${company.id}`)}
|
|
|
+ >
|
|
|
+ <div className="flex items-start space-x-3">
|
|
|
+ <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0">
|
|
|
+ {company.logo ? (
|
|
|
+ <img src={company.logo} alt={company.name} className="w-full h-full rounded-full object-cover" />
|
|
|
+ ) : (
|
|
|
+ <span className="text-lg font-bold" style={{ color: COLORS.ink.dark }}>
|
|
|
+ {company.name.slice(0, 2)}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="flex-1">
|
|
|
+ <h3 className="font-medium text-lg mb-1" style={{ color: COLORS.text.primary }}>
|
|
|
+ {company.name}
|
|
|
+ </h3>
|
|
|
+ <p className="text-sm mb-2" style={{ color: COLORS.text.secondary }}>
|
|
|
+ {company.industryCategory || '未分类'}
|
|
|
+ </p>
|
|
|
+ <p className="text-xs text-gray-600 line-clamp-2">
|
|
|
+ {company.description || '暂无简介'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="min-h-screen" style={{ backgroundColor: COLORS.ink.light }}>
|
|
|
+ {/* 搜索栏 */}
|
|
|
+ <header className="sticky top-0 z-10 shadow-sm border-b" style={{ backgroundColor: COLORS.ink.light, borderColor: COLORS.ink.medium }}>
|
|
|
+ <div className="p-4">
|
|
|
+ <div className="flex items-center space-x-2">
|
|
|
+ <div className="flex-1 flex items-center rounded-full px-4 py-3 shadow-sm border" style={{ backgroundColor: 'rgba(255,255,255,0.7)', borderColor: COLORS.ink.medium }}>
|
|
|
+ <MagnifyingGlassIcon className="w-5 h-5 mr-2" style={{ color: COLORS.ink.dark }} />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ placeholder="搜索岗位、知识、企业..."
|
|
|
+ value={searchQuery}
|
|
|
+ onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
+ onKeyPress={handleKeyPress}
|
|
|
+ className="flex-1 bg-transparent outline-none"
|
|
|
+ style={{ color: COLORS.text.primary, fontSize: '16px' }}
|
|
|
+ autoFocus
|
|
|
+ />
|
|
|
+ {searchQuery && (
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setSearchQuery('');
|
|
|
+ setSearchParams({});
|
|
|
+ }}
|
|
|
+ className="ml-2"
|
|
|
+ >
|
|
|
+ <XMarkIcon className="w-4 h-4" style={{ color: COLORS.ink.dark }} />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={() => navigate(-1)}
|
|
|
+ className="text-sm px-3 py-2"
|
|
|
+ style={{ color: COLORS.text.primary }}
|
|
|
+ >
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 搜索类型标签 */}
|
|
|
+ <div className="px-4 pb-3">
|
|
|
+ <div className="flex space-x-2 overflow-x-auto">
|
|
|
+ {(['all', 'jobs', 'knowledge', 'companies'] as const).map((type) => (
|
|
|
+ <button
|
|
|
+ key={type}
|
|
|
+ onClick={() => handleTypeChange(type)}
|
|
|
+ className={`px-4 py-2 rounded-full text-sm whitespace-nowrap transition-colors ${searchType === type ? 'text-white' : ''}`}
|
|
|
+ style={{
|
|
|
+ backgroundColor: searchType === type ? COLORS.accent.blue : 'rgba(255,255,255,0.7)',
|
|
|
+ color: searchType === type ? 'white' : COLORS.text.primary,
|
|
|
+ border: `1px solid ${COLORS.ink.medium}`
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {type === 'all' ? '全部' : type === 'jobs' ? '岗位' : type === 'knowledge' ? '知识' : '企业'}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div className="p-4">
|
|
|
+ {/* 搜索历史 */}
|
|
|
+ {!searchQuery && searchHistory.length > 0 && (
|
|
|
+ <div className="mb-6">
|
|
|
+ <div className="flex items-center justify-between mb-3">
|
|
|
+ <h3 className={FONT_STYLES.sectionTitle} style={{ color: COLORS.text.primary }}>搜索历史</h3>
|
|
|
+ <button
|
|
|
+ onClick={clearSearchHistory}
|
|
|
+ className="text-sm"
|
|
|
+ style={{ color: COLORS.text.light }}
|
|
|
+ >
|
|
|
+ 清除
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
+ {searchHistory.map((item) => (
|
|
|
+ <div key={item} className="flex items-center">
|
|
|
+ <button
|
|
|
+ onClick={() => handleSearch(item)}
|
|
|
+ className="px-3 py-1 rounded-full text-sm"
|
|
|
+ style={{ backgroundColor: 'rgba(255,255,255,0.7)', color: COLORS.text.secondary }}
|
|
|
+ >
|
|
|
+ {item}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => removeSearchHistoryItem(item)}
|
|
|
+ className="ml-1 text-xs"
|
|
|
+ style={{ color: COLORS.text.light }}
|
|
|
+ >
|
|
|
+ <XMarkIcon className="w-3 h-3" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 热门搜索 */}
|
|
|
+ {!searchQuery && (
|
|
|
+ <div className="mb-6">
|
|
|
+ <h3 className={`${FONT_STYLES.sectionTitle} mb-3`} style={{ color: COLORS.text.primary }}>热门搜索</h3>
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
+ {hotSearches.map((item) => (
|
|
|
+ <button
|
|
|
+ key={item}
|
|
|
+ onClick={() => handleSearch(item)}
|
|
|
+ className="px-3 py-1 rounded-full text-sm"
|
|
|
+ style={{ backgroundColor: 'rgba(255,255,255,0.7)', color: COLORS.text.secondary }}
|
|
|
+ >
|
|
|
+ {item}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 搜索结果 */}
|
|
|
+ {searchQuery && (
|
|
|
+ <div>
|
|
|
+ {isLoading ? (
|
|
|
+ <SkeletonLoader count={3} />
|
|
|
+ ) : isError ? (
|
|
|
+ <div className="text-center py-8">
|
|
|
+ <p style={{ color: COLORS.text.secondary }}>搜索失败,请稍后重试</p>
|
|
|
+ </div>
|
|
|
+ ) : searchResults ? (
|
|
|
+ <div>
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
+ <h3 className={FONT_STYLES.sectionTitle} style={{ color: COLORS.text.primary }}>
|
|
|
+ 搜索结果 ({getResultCount()}条)
|
|
|
+ </h3>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {searchType === 'all' && (
|
|
|
+ <>
|
|
|
+ {searchResults.jobs?.length > 0 && (
|
|
|
+ <>
|
|
|
+ <h4 className={`${FONT_STYLES.sectionTitle} mb-3 text-lg`} style={{ color: COLORS.text.primary }}>相关岗位</h4>
|
|
|
+ {searchResults.jobs.map(renderJobCard)}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {searchResults.knowledge?.length > 0 && (
|
|
|
+ <>
|
|
|
+ <h4 className={`${FONT_STYLES.sectionTitle} mb-3 text-lg`} style={{ color: COLORS.text.primary }}>相关知识</h4>
|
|
|
+ {searchResults.knowledge.map(renderKnowledgeCard)}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {searchResults.companies?.length > 0 && (
|
|
|
+ <>
|
|
|
+ <h4 className={`${FONT_STYLES.sectionTitle} mb-3 text-lg`} style={{ color: COLORS.text.primary }}>相关企业</h4>
|
|
|
+ {searchResults.companies.map(renderCompanyCard)}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {searchType === 'jobs' && searchResults.jobs?.map(renderJobCard)}
|
|
|
+ {searchType === 'knowledge' && searchResults.knowledge?.map(renderKnowledgeCard)}
|
|
|
+ {searchType === 'companies' && searchResults.companies?.map(renderCompanyCard)}
|
|
|
+
|
|
|
+ {getResultCount() === 0 && (
|
|
|
+ <div className="text-center py-8">
|
|
|
+ <p className="text-lg mb-2" style={{ color: COLORS.text.primary }}>没有找到相关结果</p>
|
|
|
+ <p style={{ color: COLORS.text.secondary }}>试试其他关键词或调整搜索条件</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default SearchPage;
|