From d4017447391e0fa26cee15d0e683597fc7a15e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Fri, 19 Jun 2026 09:59:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(TopBar):=20=E6=B7=BB=E5=8A=A0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=8A=9F=E8=83=BD=E7=BB=84=E4=BB=B6=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SearchInput、SearchDropdown、SearchResultItem 和 TopBarActions 组件,增强 TopBar 的搜索功能。 - 更新 useTopBar 钩子以支持最近搜索功能,并优化搜索历史管理。 - 修改 TopBar 组件以整合新组件,提升可读性和可维护性。 - 更新 README.md,反映新增组件和功能。 --- src/layout/README.md | 10 +- src/layout/TopBar/SearchDropdown.tsx | 45 ++++++ src/layout/TopBar/SearchInput.tsx | 56 ++++++++ src/layout/TopBar/SearchResultItem.tsx | 34 +++++ src/layout/TopBar/TopBarActions.tsx | 34 +++++ src/layout/TopBar/constants.ts | 3 + src/layout/TopBar/index.tsx | 183 +++++++------------------ src/layout/TopBar/useTopBar.ts | 52 +++---- 8 files changed, 242 insertions(+), 175 deletions(-) create mode 100644 src/layout/TopBar/SearchDropdown.tsx create mode 100644 src/layout/TopBar/SearchInput.tsx create mode 100644 src/layout/TopBar/SearchResultItem.tsx create mode 100644 src/layout/TopBar/TopBarActions.tsx diff --git a/src/layout/README.md b/src/layout/README.md index 15be8a1..3e295a3 100644 --- a/src/layout/README.md +++ b/src/layout/README.md @@ -14,9 +14,13 @@ ``` layout/TopBar/ -├── index.tsx # 布局 UI -├── useTopBar.ts # 业务逻辑 Hook -├── constants.ts # 常量 +├── index.tsx # 布局入口 +├── useTopBar.ts # 业务逻辑 Hook +├── constants.ts # 常量 +├── SearchInput.tsx # 搜索输入框 +├── SearchDropdown.tsx # 搜索结果/历史下拉面板 +├── SearchResultItem.tsx # 单条搜索结果项 +├── TopBarActions.tsx # 右侧操作按钮组 └── __tests__/ └── index.test.tsx ``` diff --git a/src/layout/TopBar/SearchDropdown.tsx b/src/layout/TopBar/SearchDropdown.tsx new file mode 100644 index 0000000..17368c4 --- /dev/null +++ b/src/layout/TopBar/SearchDropdown.tsx @@ -0,0 +1,45 @@ +import { type FeatureConfig } from '@/config/features'; +import SearchResultItem from './SearchResultItem'; + +interface SearchDropdownProps { + searchQuery: string; + searchResults: FeatureConfig[]; + recentFeatures: FeatureConfig[]; + selectedIndex: number; + onSelect: (feature: FeatureConfig) => void; +} + +export default function SearchDropdown({ + searchQuery, + searchResults, + recentFeatures, + selectedIndex, + onSelect, +}: SearchDropdownProps) { + const isSearching = searchQuery.trim().length > 0; + const items = isSearching ? searchResults : recentFeatures; + + return ( +
+ +
+ ); +} diff --git a/src/layout/TopBar/SearchInput.tsx b/src/layout/TopBar/SearchInput.tsx new file mode 100644 index 0000000..9b4ecd5 --- /dev/null +++ b/src/layout/TopBar/SearchInput.tsx @@ -0,0 +1,56 @@ +import { type RefObject } from 'react'; +import { Search, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +interface SearchInputProps { + inputRef: RefObject; + searchQuery: string; + onSearchQueryChange: (value: string) => void; + onFocus: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onClear: () => void; +} + +export default function SearchInput({ + inputRef, + searchQuery, + onSearchQueryChange, + onFocus, + onKeyDown, + onClear, +}: SearchInputProps) { + return ( +
+ + onSearchQueryChange(e.target.value)} + onFocus={onFocus} + onKeyDown={onKeyDown} + aria-label="搜索工具..." + className="h-9 rounded-lg border-border/60 bg-muted/40 pl-9 pr-16 shadow-none focus-visible:ring-1 focus-visible:ring-offset-0 placeholder:text-muted-foreground/50" + /> + {!searchQuery && ( + + ⌘K + + )} + {searchQuery && ( + + )} +
+ ); +} diff --git a/src/layout/TopBar/SearchResultItem.tsx b/src/layout/TopBar/SearchResultItem.tsx new file mode 100644 index 0000000..db020a9 --- /dev/null +++ b/src/layout/TopBar/SearchResultItem.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { type FeatureConfig } from '@/config/features'; +import { cn } from '@/lib/utils'; + +interface SearchResultItemProps { + feature: FeatureConfig; + selected: boolean; + onSelect: (feature: FeatureConfig) => void; +} + +const SearchResultItem = React.memo(({ feature, selected, onSelect }: SearchResultItemProps) => { + return ( +
  • onSelect(feature)} + className={cn( + 'flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors', + selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60', + )} + > +
    + {feature.icon && } +
    +
    +

    {feature.label}

    +

    {feature.description}

    +
    +
  • + ); +}); +SearchResultItem.displayName = 'SearchResultItem'; + +export default SearchResultItem; diff --git a/src/layout/TopBar/TopBarActions.tsx b/src/layout/TopBar/TopBarActions.tsx new file mode 100644 index 0000000..f9d5d80 --- /dev/null +++ b/src/layout/TopBar/TopBarActions.tsx @@ -0,0 +1,34 @@ +import { type LucideIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface TopBarAction { + id: string; + icon: LucideIcon; + title: string; + onClick: () => void; +} + +interface TopBarActionsProps { + actions: TopBarAction[]; +} + +export default function TopBarActions({ actions }: TopBarActionsProps) { + return ( +
    + {actions.map(({ id, icon: Icon, title, onClick }) => ( + + ))} +
    + ); +} diff --git a/src/layout/TopBar/constants.ts b/src/layout/TopBar/constants.ts index a297476..33d2a51 100644 --- a/src/layout/TopBar/constants.ts +++ b/src/layout/TopBar/constants.ts @@ -1,2 +1,5 @@ export const SEARCH_HISTORY_LIMIT = 10; export const SEARCH_HISTORY_DISPLAY = 5; + +export const isSearchHistory = (val: unknown): val is string[] => + Array.isArray(val) && val.every((item) => typeof item === 'string'); diff --git a/src/layout/TopBar/index.tsx b/src/layout/TopBar/index.tsx index f555b91..3a66eb4 100644 --- a/src/layout/TopBar/index.tsx +++ b/src/layout/TopBar/index.tsx @@ -1,7 +1,8 @@ -import React from 'react'; -import { ArrowLeft, ExternalLink, Search, X } from 'lucide-react'; -import { type FeatureConfig } from '@/config/features'; -import { cn } from '@/lib/utils'; +import { ArrowLeft, ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import SearchDropdown from './SearchDropdown'; +import SearchInput from './SearchInput'; +import TopBarActions from './TopBarActions'; import { useTopBar } from './useTopBar'; export default function TopBar() { @@ -9,7 +10,7 @@ export default function TopBar() { searchQuery, showResults, searchResults, - displayedHistory, + recentFeatures, selectedIndex, isDashboard, ThemeIcon, @@ -27,154 +28,64 @@ export default function TopBar() { clearSearch, } = useTopBar(); + const actions = [ + { id: 'theme', icon: ThemeIcon, title: themeTitle, onClick: cycleThemeMode }, + { + id: 'open-in-tab', + icon: ExternalLink, + title: '在标签页打开', + onClick: () => { + void handleOpenInTab(); + }, + }, + ]; + + const handleSearchQueryChange = (value: string) => { + setSearchQuery(value); + setShowResults(true); + setSelectedIndex(-1); + }; + + const showDropdown = showResults && (searchQuery.trim() || recentFeatures.length > 0); + return (
    {!isDashboard && ( - + )}
    -
    - - { - setSearchQuery(e.target.value); - setShowResults(true); - setSelectedIndex(-1); - }} - onFocus={() => setShowResults(true)} - onKeyDown={handleKeyDown} - aria-label="搜索工具..." - className="h-9 w-full rounded-lg border border-border/60 bg-muted/40 pl-9 pr-16 text-sm transition-all placeholder:text-muted-foreground/50 focus:border-input focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring" + setShowResults(true)} + onKeyDown={handleKeyDown} + onClear={clearSearch} + /> + {showDropdown && ( + - {!searchQuery && ( - - ⌘K - - )} - {searchQuery && ( - - )} -
    - - {showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( -
    -
      - {searchQuery.trim() ? ( - searchResults.length > 0 ? ( - searchResults.map((feature, index) => ( - - )) - ) : ( -
    • - 未找到相关工具 -
    • - ) - ) : ( - <> -
      - 最近搜索 -
      - {displayedHistory.map((item, index) => - item.feature ? ( - - ) : null, - )} - - )} -
    -
    )}
    -
    - - - - - - -
    +
    ); } - -interface SearchResultItemProps { - feature: FeatureConfig; - selected: boolean; - onSelect: (feature: FeatureConfig) => void; -} - -function SearchResultItem({ feature, selected, onSelect }: SearchResultItemProps) { - return ( -
  • onSelect(feature)} - className={cn( - 'flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors', - selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60', - )} - > -
    - {feature.icon && } -
    -
    -

    {feature.label}

    -

    {feature.description}

    -
    -
  • - ); -} - -function IconButton({ - children, - onClick, - title, -}: { - children: React.ReactNode; - onClick: () => void; - title: string; -}) { - return ( - - ); -} diff --git a/src/layout/TopBar/useTopBar.ts b/src/layout/TopBar/useTopBar.ts index 02f989a..632b103 100644 --- a/src/layout/TopBar/useTopBar.ts +++ b/src/layout/TopBar/useTopBar.ts @@ -3,20 +3,15 @@ import { Monitor, Moon, Sun } from 'lucide-react'; import { useRouter } from '@/providers/RouterProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider'; import { type FeatureConfig, FEATURES } from '@/config/features'; -import { storageUtil } from '@/utils/chromeStorage'; +import { useStorageState } from '@/utils/useStorageState'; import { openExtensionPage } from '@/utils/chromeTabs'; -import { SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants'; - -export interface HistoryItem { - key: string; - feature?: FeatureConfig; -} +import { isSearchHistory, SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants'; export interface UseTopBarReturn { searchQuery: string; showResults: boolean; searchResults: FeatureConfig[]; - displayedHistory: HistoryItem[]; + recentFeatures: FeatureConfig[]; selectedIndex: number; isDashboard: boolean; ThemeIcon: typeof Sun; @@ -40,7 +35,11 @@ export function useTopBar(): UseTopBarReturn { const [searchQuery, setSearchQuery] = useState(''); const [showResults, setShowResults] = useState(false); - const [searchHistory, setSearchHistory] = useState([]); + const [searchHistory, setSearchHistory] = useStorageState( + 'app/searchHistory', + [], + isSearchHistory, + ); const [selectedIndex, setSelectedIndex] = useState(-1); const containerRef = useRef(null); @@ -73,22 +72,6 @@ export function useTopBar(): UseTopBarReturn { return () => document.removeEventListener('keydown', handleGlobalKeyDown); }, []); - useEffect(() => { - let cancelled = false; - - storageUtil - .get('app/searchHistory', []) - .then((history) => { - if (cancelled || !history) return; - setSearchHistory(history); - }) - .catch((err) => console.error('加载搜索历史失败:', err)); - - return () => { - cancelled = true; - }; - }, []); - const searchResults = useMemo(() => { const query = searchQuery.trim().toLowerCase(); if (!query) return []; @@ -102,18 +85,15 @@ export function useTopBar(): UseTopBarReturn { if (searchQuery.trim()) return []; return searchHistory .slice(0, SEARCH_HISTORY_DISPLAY) - .map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) })) - .filter((item) => item.feature && item.feature.key !== 'dashboard'); + .map((key) => FEATURES.find((f) => f.key === key)) + .filter((feature): feature is FeatureConfig => !!feature && feature.key !== 'dashboard'); }, [searchHistory, searchQuery]); - const saveToHistory = async (featureKey: string) => { + const saveToHistory = (featureKey: string) => { if (!featureKey.trim()) return; - const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice( - 0, - SEARCH_HISTORY_LIMIT, + setSearchHistory((prev) => + [featureKey, ...prev.filter((h) => h !== featureKey)].slice(0, SEARCH_HISTORY_LIMIT), ); - setSearchHistory(nextHistory); - await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err)); }; const handleSelectFeature = (feature: FeatureConfig) => { @@ -149,8 +129,8 @@ export function useTopBar(): UseTopBarReturn { handleSelectFeature(searchResults[selectedIndex]); } else { const selected = displayedHistory[selectedIndex]; - if (selected?.feature) { - handleSelectFeature(selected.feature); + if (selected) { + handleSelectFeature(selected); } } } else if (searchQuery.trim() && searchResults.length > 0) { @@ -171,7 +151,7 @@ export function useTopBar(): UseTopBarReturn { searchQuery, showResults, searchResults, - displayedHistory, + recentFeatures: displayedHistory, selectedIndex, isDashboard: currentPage === 'dashboard', ThemeIcon,