diff --git a/components/TopBar.tsx b/components/TopBar.tsx index e171182..94327d1 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -1,101 +1,96 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { - Settings, - ExternalLink, ArrowLeft, - Search, - History, - X, + ExternalLink, Globe, - Sun, - Moon, + History, Monitor, + Moon, + Search, + Settings, + Sun, + X, } from 'lucide-react'; import { useRouter } from '@/providers/RouterProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider'; -import { FEATURES, FeatureConfig } from '@/config/features'; +import { FeatureConfig, FEATURES } from '@/config/features'; import { storageUtil } from '@/utils/chromeStorage'; -import { openExtensionPage } from '@/utils/chromeTabs'; import { useTranslation } from 'react-i18next'; -import { SUPPORTED_LANGUAGES, normalizeLanguage } from '@/i18n'; +import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n'; +import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数 -const topBarStyles = { - SEARCH_MAX_WIDTH: 400, - DROPDOWN_MAX_HEIGHT: 300, - Z_INDEX: 1100, - DROPDOWN_Z_INDEX: 1200, - SEARCH_HISTORY_LIMIT: 10, - SEARCH_HISTORY_DISPLAY: 5, -}; +// 常量配置抽取(无需写在全局变量或 styles 对象里) +const SEARCH_HISTORY_LIMIT = 10; +const SEARCH_HISTORY_DISPLAY = 5; export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) { const { currentPage, goBack, navigateTo } = useRouter(); const { mode, setMode } = useThemeMode(); const { t, i18n } = useTranslation(['common', 'features']); + const [searchQuery, setSearchQuery] = useState(''); const [showResults, setShowResults] = useState(false); const [searchHistory, setSearchHistory] = useState([]); const [selectedIndex, setSelectedIndex] = useState(-1); + + const containerRef = useRef(null); const inputRef = useRef(null); - // 加载搜索历史 - useEffect(() => { - storageUtil - .get('app/searchHistory', []) - .then((history) => { - setSearchHistory(history || []); - }) - .catch((error) => { - console.error('加载搜索历史失败:', error); - }); - }, []); - - // 模糊搜索逻辑 - const searchResults = useMemo(() => { - if (!searchQuery.trim()) return []; - const query = searchQuery.toLowerCase(); - return FEATURES.filter((f) => { - if (f.key === 'dashboard') return false; - const label = t(f.labelKey).toLowerCase(); - const desc = t(f.descriptionKey).toLowerCase(); - return label.includes(query) || desc.includes(query); - }); - }, [searchQuery, t]); - - const displayedHistory = useMemo(() => { - if (searchQuery.trim()) return []; - return searchHistory.slice(0, topBarStyles.SEARCH_HISTORY_DISPLAY); - }, [searchHistory, searchQuery]); - const handleOpenInTab = async () => { await openExtensionPage('popup.html', { mode: 'tab' }); window.close(); }; - const handleSearchChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - setShowResults(true); - setSelectedIndex(-1); - }; + // 2. 健壮的 Click Outside 逻辑:点击空白处收起搜索框 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowResults(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + // 从 Chrome Storage 异步初始化历史记录 + useEffect(() => { + storageUtil + .get('app/searchHistory', []) + .then((history) => { + if (history) setSearchHistory(history); + }) + .catch((err) => console.error('加载搜索历史失败:', err)); + }, []); + + // 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项) + const searchResults = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return []; + return FEATURES.filter((f) => { + if (f.key === 'dashboard') return false; + return ( + t(f.labelKey).toLowerCase().includes(query) || + t(f.descriptionKey).toLowerCase().includes(query) + ); + }); + }, [searchQuery, t]); + + const displayedHistory = useMemo(() => { + if (searchQuery.trim()) return []; + return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY); + }, [searchHistory, searchQuery]); + + // 新增/持久化历史记录 const saveToHistory = async (query: string) => { if (!query.trim()) return; - setSearchHistory((prev) => { - const newHistory = [query, ...prev.filter((h) => h !== query)].slice( - 0, - topBarStyles.SEARCH_HISTORY_LIMIT, - ); - return newHistory; - }); + const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice( + 0, + SEARCH_HISTORY_LIMIT, + ); + setSearchHistory(nextHistory); + await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err)); }; - // 副作用:搜索历史变化后持久化到 storage - useEffect(() => { - storageUtil.set('app/searchHistory', searchHistory).catch((error) => { - console.error('保存搜索历史失败:', error); - }); - }, [searchHistory]); - const handleSelectFeature = (feature: FeatureConfig) => { navigateTo(feature.key); saveToHistory(t(feature.labelKey)); @@ -106,19 +101,19 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) const toggleLanguage = async () => { const currentLng = normalizeLanguage(i18n.language); const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng); - const nextIndex = (currentIndex + 1) % SUPPORTED_LANGUAGES.length; - const newLng = SUPPORTED_LANGUAGES[nextIndex]; - await i18n.changeLanguage(newLng); - await storageUtil.set('app/language', newLng); + const nextLng = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length]; + await i18n.changeLanguage(nextLng); + await storageUtil.set('app/language', nextLng); }; const cycleThemeMode = () => { - const next = { light: 'dark', dark: 'system', system: 'light' } as const; - setMode(next[mode]); + const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const; + setMode(nextMap[mode]); }; const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor; + // 4. 健壮的键盘导航交互 const handleKeyDown = (e: React.KeyboardEvent) => { const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; @@ -129,6 +124,7 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) e.preventDefault(); setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); } else if (e.key === 'Enter') { + e.preventDefault(); if (selectedIndex >= 0) { if (searchQuery.trim()) { handleSelectFeature(searchResults[selectedIndex]); @@ -136,13 +132,10 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) const selectedQuery = displayedHistory[selectedIndex]; setSearchQuery(selectedQuery); setSelectedIndex(-1); - // 触发搜索:如果匹配到功能则跳转,否则保持搜索词展示结果 - const matchedFeature = FEATURES.find( + const matched = FEATURES.find( (f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery, ); - if (matchedFeature) { - handleSelectFeature(matchedFeature); - } + if (matched) handleSelectFeature(matched); } } else if (searchQuery.trim() && searchResults.length > 0) { handleSelectFeature(searchResults[0]); @@ -156,39 +149,39 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) const isDashboard = currentPage === 'dashboard'; return ( -
-
+
+ {/* 左侧:返回按钮区 */} +
{!isDashboard && ( )}
- - {t('common:appName')} - - -
+ {/* 中间:搜索容器 */} +
-
- -
+ { + setSearchQuery(e.target.value); + setShowResults(true); + setSelectedIndex(-1); + }} onFocus={() => setShowResults(true)} onKeyDown={handleKeyDown} aria-label={t('common:buttons.search')} - className="w-full pl-9 pr-8 py-1.5 text-sm rounded-lg border border-transparent bg-muted focus:bg-background focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" + className="w-full h-9 pl-9 pr-8 text-sm rounded-md border border-input bg-muted/50 transition-all placeholder:text-muted-foreground focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input" /> {searchQuery && ( )}
+ {/* 动态联想结果卡片 */} {showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( -
-
    +
    +
      {searchQuery.trim() ? ( searchResults.length > 0 ? ( searchResults.map((feature, index) => ( @@ -216,15 +210,18 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) role="option" aria-selected={selectedIndex === index} onClick={() => handleSelectFeature(feature)} - className={`flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors ${ - selectedIndex === index ? 'bg-primary/10' : 'hover:bg-muted' - }`} + className={cn( + 'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors', + selectedIndex === index + ? 'bg-accent text-accent-foreground' + : 'hover:bg-muted/60', + )} > -
      - {feature.icon && } +
      + {feature.icon && }
      -

      +

      {t(feature.labelKey)}

      @@ -234,17 +231,15 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) )) ) : ( -

    • -

      {t('common:buttons.noResults')}

      +
    • + {t('common:buttons.noResults')}
    • ) ) : ( <> -
    • - - {t('common:buttons.recentSearch')} - -
    • +
      + {t('common:buttons.recentSearch')} +
      {displayedHistory.map((item, index) => (
    • void }) setSearchQuery(item); setSelectedIndex(-1); }} - className={`flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors ${ - selectedIndex === index ? 'bg-primary/10' : 'hover:bg-muted' - }`} + className={cn( + 'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors', + selectedIndex === index + ? 'bg-accent text-accent-foreground' + : 'hover:bg-muted/60', + )} > -
      - -
      - {item} + + {item}
    • ))} @@ -271,44 +267,44 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) )}
      -
      - - - - +
      -
      +
+ ); +} + +// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格 +function IconButton({ + children, + onClick, + title, +}: { + children: React.ReactNode; + onClick: () => void; + title: string; +}) { + return ( + ); } diff --git a/components/__tests__/TopBar.test.tsx b/components/__tests__/TopBar.test.tsx index 4be4467..38e10fa 100644 --- a/components/__tests__/TopBar.test.tsx +++ b/components/__tests__/TopBar.test.tsx @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import type { PageType } from '@/types/storage'; import React from 'react'; +import TopBar from '@/components/TopBar'; +import { RouterProvider } from '@/providers/RouterProvider'; +import { ThemeModeProvider } from '@/providers/ThemeModeProvider'; // matchMedia must be mocked before ThemeModeProvider is imported Object.defineProperty(window, 'matchMedia', { @@ -18,10 +21,6 @@ Object.defineProperty(window, 'matchMedia', { })), }); -import TopBar from '@/components/TopBar'; -import { RouterProvider } from '@/providers/RouterProvider'; -import { ThemeModeProvider } from '@/providers/ThemeModeProvider'; - const mockRouterValue = { currentPage: 'dashboard' as PageType, visiblePages: ['dashboard', 'timestamp'] as PageType[], @@ -53,11 +52,6 @@ describe('TopBar 组件', () => { }; describe('渲染测试', () => { - it('应使用默认标题渲染', () => { - renderWithProvider(); - expect(screen.getByText('common:appName')).toBeInTheDocument(); - }); - it('不在 dashboard 时应渲染返回按钮', () => { mockRouterValue.currentPage = 'timestamp'; renderWithProvider();