Просмотр исходного кода

✨ feat(mobile): 添加搜索页面功能

- 实现移动端搜索页面,支持搜索岗位、知识和企业
- 添加搜索历史记录功能,支持保存和清除历史记录
- 实现热门搜索推荐功能,展示热门搜索关键词
- 添加搜索结果分类展示,支持按类型筛选结果
- 优化搜索体验,添加加载状态和空结果提示

✨ feat(dashboard): 优化数据大屏标题栏

- 在标题栏添加系统logo,增强品牌识别度
- 调整标题栏布局为两端对齐,优化视觉效果
- 保持数字时钟和全屏切换功能的可用性
yourname 8 месяцев назад
Родитель
Сommit
cd6219a15b

+ 12 - 5
src/client/big-shadcn/pages/HomePage.tsx

@@ -379,11 +379,18 @@ const DashboardGrid = () => {
         animate={{ opacity: 1, y: 0 }}
         transition={{ duration: 0.8 }}
       >
-        <div className="h-full glass-card rounded-xl flex items-center justify-center px-6">
-          <h1 className="text-2xl md:text-4xl font-bold bg-gradient-to-r from-cyan-400 to-purple-400 bg-clip-text text-transparent">
-            银龄智慧数据大屏
-          </h1>
-          <div className="absolute right-6 flex items-center space-x-4">
+        <div className="h-full glass-card rounded-xl flex items-center justify-between px-6">
+          <div className="flex items-center space-x-4">
+            <img
+              src="/yizhihuilogo.png"
+              alt="银龄智慧"
+              className="h-12 w-auto object-contain"
+            />
+            <h1 className="text-2xl md:text-4xl font-bold bg-gradient-to-r from-cyan-400 to-purple-400 bg-clip-text text-transparent">
+              银龄智慧数据大屏
+            </h1>
+          </div>
+          <div className="flex items-center space-x-4">
             <DigitalClock />
             <FullscreenToggle />
           </div>

+ 447 - 0
src/client/mobile/pages/SearchPage.tsx

@@ -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;

+ 5 - 0
src/client/mobile/routes.tsx

@@ -31,6 +31,7 @@ import FontSettingsPage from './pages/FontSettingsPage';
 import LoginPage from './pages/LoginPage';
 import RegisterPage from './pages/RegisterPage';
 import AIAgentsPage from './pages/AIAgentsPage';
+import SearchPage from './pages/SearchPage';
 
 export const router = createBrowserRouter([
   {
@@ -160,6 +161,10 @@ export const router = createBrowserRouter([
             <FontSettingsPage />
           </ProtectedRoute>
         )
+      },
+      {
+        path: 'search',
+        element: <SearchPage />
       }
     ]
   },