feat(TopBar): 添加搜索功能组件并优化结构

- 新增 SearchInput、SearchDropdown、SearchResultItem 和 TopBarActions 组件,增强 TopBar 的搜索功能。
- 更新 useTopBar 钩子以支持最近搜索功能,并优化搜索历史管理。
- 修改 TopBar 组件以整合新组件,提升可读性和可维护性。
- 更新 README.md,反映新增组件和功能。
This commit is contained in:
雨霖铃
2026-06-19 09:59:15 +08:00
parent 2c901ab4fd
commit d401744739
8 changed files with 242 additions and 175 deletions
+5 -1
View File
@@ -14,9 +14,13 @@
``` ```
layout/TopBar/ layout/TopBar/
├── index.tsx # 布局 UI ├── index.tsx # 布局入口
├── useTopBar.ts # 业务逻辑 Hook ├── useTopBar.ts # 业务逻辑 Hook
├── constants.ts # 常量 ├── constants.ts # 常量
├── SearchInput.tsx # 搜索输入框
├── SearchDropdown.tsx # 搜索结果/历史下拉面板
├── SearchResultItem.tsx # 单条搜索结果项
├── TopBarActions.tsx # 右侧操作按钮组
└── __tests__/ └── __tests__/
└── index.test.tsx └── index.test.tsx
``` ```
+45
View File
@@ -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 (
<div className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-80 overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in slide-in-from-top-2 duration-150">
<ul role="listbox" className="p-1.5">
{!isSearching && items.length > 0 && (
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
</div>
)}
{isSearching && items.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-muted-foreground"></li>
) : (
items.map((feature, index) => (
<SearchResultItem
key={feature.key}
feature={feature}
selected={selectedIndex === index}
onSelect={onSelect}
/>
))
)}
</ul>
</div>
);
}
+56
View File
@@ -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<HTMLInputElement | null>;
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 (
<div className="group relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60 transition-colors group-focus-within:text-muted-foreground" />
<Input
ref={inputRef}
type="text"
placeholder="搜索工具..."
value={searchQuery}
onChange={(e) => 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 && (
<kbd className="pointer-events-none absolute right-3 top-1/2 hidden h-5 -translate-y-1/2 items-center gap-0.5 rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 sm:inline-flex">
K
</kbd>
)}
{searchQuery && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClear}
aria-label="清除搜索"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
);
}
+34
View File
@@ -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 (
<li
role="option"
aria-selected={selected}
onClick={() => 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',
)}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/80 text-muted-foreground">
{feature.icon && <feature.icon className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{feature.label}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{feature.description}</p>
</div>
</li>
);
});
SearchResultItem.displayName = 'SearchResultItem';
export default SearchResultItem;
+34
View File
@@ -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 (
<div className="flex shrink-0 items-center gap-1">
{actions.map(({ id, icon: Icon, title, onClick }) => (
<Button
key={id}
type="button"
variant="ghost"
size="icon"
onClick={onClick}
title={title}
aria-label={title}
className="h-8 w-8 text-muted-foreground"
>
<Icon className="h-4 w-4" />
</Button>
))}
</div>
);
}
+3
View File
@@ -1,2 +1,5 @@
export const SEARCH_HISTORY_LIMIT = 10; export const SEARCH_HISTORY_LIMIT = 10;
export const SEARCH_HISTORY_DISPLAY = 5; export const SEARCH_HISTORY_DISPLAY = 5;
export const isSearchHistory = (val: unknown): val is string[] =>
Array.isArray(val) && val.every((item) => typeof item === 'string');
+45 -134
View File
@@ -1,7 +1,8 @@
import React from 'react'; import { ArrowLeft, ExternalLink } from 'lucide-react';
import { ArrowLeft, ExternalLink, Search, X } from 'lucide-react'; import { Button } from '@/components/ui/button';
import { type FeatureConfig } from '@/config/features'; import SearchDropdown from './SearchDropdown';
import { cn } from '@/lib/utils'; import SearchInput from './SearchInput';
import TopBarActions from './TopBarActions';
import { useTopBar } from './useTopBar'; import { useTopBar } from './useTopBar';
export default function TopBar() { export default function TopBar() {
@@ -9,7 +10,7 @@ export default function TopBar() {
searchQuery, searchQuery,
showResults, showResults,
searchResults, searchResults,
displayedHistory, recentFeatures,
selectedIndex, selectedIndex,
isDashboard, isDashboard,
ThemeIcon, ThemeIcon,
@@ -27,154 +28,64 @@ export default function TopBar() {
clearSearch, clearSearch,
} = useTopBar(); } = 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 ( return (
<header className="relative z-50 flex h-14 items-center justify-between border-b border-border bg-background px-4"> <header className="relative z-50 flex h-14 items-center justify-between border-b border-border bg-background px-4">
<div className="flex w-10 items-center justify-start"> <div className="flex w-10 items-center justify-start">
{!isDashboard && ( {!isDashboard && (
<button <Button
type="button" type="button"
variant="outline"
size="icon"
onClick={goHome} onClick={goHome}
aria-label="返回首页" aria-label="返回首页"
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground" className="h-8 w-8 shadow-sm text-muted-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</button> </Button>
)} )}
</div> </div>
<div ref={containerRef} className="relative mx-4 max-w-md flex-1"> <div ref={containerRef} className="relative mx-4 max-w-md flex-1">
<div className="group relative"> <SearchInput
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60 transition-colors group-focus-within:text-muted-foreground" /> inputRef={inputRef}
<input searchQuery={searchQuery}
ref={inputRef} onSearchQueryChange={handleSearchQueryChange}
type="text"
placeholder="搜索工具..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowResults(true);
setSelectedIndex(-1);
}}
onFocus={() => setShowResults(true)} onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label="搜索工具..." onClear={clearSearch}
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" />
{showDropdown && (
<SearchDropdown
searchQuery={searchQuery}
searchResults={searchResults}
recentFeatures={recentFeatures}
selectedIndex={selectedIndex}
onSelect={handleSelectFeature}
/> />
{!searchQuery && (
<kbd className="pointer-events-none absolute right-3 top-1/2 hidden h-5 -translate-y-1/2 items-center gap-0.5 rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 sm:inline-flex">
K
</kbd>
)}
{searchQuery && (
<button
type="button"
onClick={clearSearch}
aria-label="清除搜索"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)} )}
</div> </div>
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( <TopBarActions actions={actions} />
<div className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-80 overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in slide-in-from-top-2 duration-150">
<ul role="listbox" className="p-1.5">
{searchQuery.trim() ? (
searchResults.length > 0 ? (
searchResults.map((feature, index) => (
<SearchResultItem
key={feature.key}
feature={feature}
selected={selectedIndex === index}
onSelect={handleSelectFeature}
/>
))
) : (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
</li>
)
) : (
<>
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
</div>
{displayedHistory.map((item, index) =>
item.feature ? (
<SearchResultItem
key={item.key}
feature={item.feature}
selected={selectedIndex === index}
onSelect={handleSelectFeature}
/>
) : null,
)}
</>
)}
</ul>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<IconButton onClick={cycleThemeMode} title={themeTitle}>
<ThemeIcon className="h-4 w-4" />
</IconButton>
<IconButton onClick={handleOpenInTab} title="在标签页打开">
<ExternalLink className="h-4 w-4" />
</IconButton>
</div>
</header> </header>
); );
} }
interface SearchResultItemProps {
feature: FeatureConfig;
selected: boolean;
onSelect: (feature: FeatureConfig) => void;
}
function SearchResultItem({ feature, selected, onSelect }: SearchResultItemProps) {
return (
<li
role="option"
aria-selected={selected}
onClick={() => 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',
)}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/80 text-muted-foreground">
{feature.icon && <feature.icon className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{feature.label}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{feature.description}</p>
</div>
</li>
);
}
function IconButton({
children,
onClick,
title,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{children}
</button>
);
}
+16 -36
View File
@@ -3,20 +3,15 @@ import { Monitor, Moon, Sun } from 'lucide-react';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider';
import { type FeatureConfig, FEATURES } from '@/config/features'; import { type FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage'; import { useStorageState } from '@/utils/useStorageState';
import { openExtensionPage } from '@/utils/chromeTabs'; import { openExtensionPage } from '@/utils/chromeTabs';
import { SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants'; import { isSearchHistory, SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants';
export interface HistoryItem {
key: string;
feature?: FeatureConfig;
}
export interface UseTopBarReturn { export interface UseTopBarReturn {
searchQuery: string; searchQuery: string;
showResults: boolean; showResults: boolean;
searchResults: FeatureConfig[]; searchResults: FeatureConfig[];
displayedHistory: HistoryItem[]; recentFeatures: FeatureConfig[];
selectedIndex: number; selectedIndex: number;
isDashboard: boolean; isDashboard: boolean;
ThemeIcon: typeof Sun; ThemeIcon: typeof Sun;
@@ -40,7 +35,11 @@ export function useTopBar(): UseTopBarReturn {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [searchHistory, setSearchHistory] = useState<string[]>([]); const [searchHistory, setSearchHistory] = useStorageState(
'app/searchHistory',
[],
isSearchHistory,
);
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -73,22 +72,6 @@ export function useTopBar(): UseTopBarReturn {
return () => document.removeEventListener('keydown', handleGlobalKeyDown); 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 searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
if (!query) return []; if (!query) return [];
@@ -102,18 +85,15 @@ export function useTopBar(): UseTopBarReturn {
if (searchQuery.trim()) return []; if (searchQuery.trim()) return [];
return searchHistory return searchHistory
.slice(0, SEARCH_HISTORY_DISPLAY) .slice(0, SEARCH_HISTORY_DISPLAY)
.map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) })) .map((key) => FEATURES.find((f) => f.key === key))
.filter((item) => item.feature && item.feature.key !== 'dashboard'); .filter((feature): feature is FeatureConfig => !!feature && feature.key !== 'dashboard');
}, [searchHistory, searchQuery]); }, [searchHistory, searchQuery]);
const saveToHistory = async (featureKey: string) => { const saveToHistory = (featureKey: string) => {
if (!featureKey.trim()) return; if (!featureKey.trim()) return;
const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice( setSearchHistory((prev) =>
0, [featureKey, ...prev.filter((h) => h !== featureKey)].slice(0, SEARCH_HISTORY_LIMIT),
SEARCH_HISTORY_LIMIT,
); );
setSearchHistory(nextHistory);
await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err));
}; };
const handleSelectFeature = (feature: FeatureConfig) => { const handleSelectFeature = (feature: FeatureConfig) => {
@@ -149,8 +129,8 @@ export function useTopBar(): UseTopBarReturn {
handleSelectFeature(searchResults[selectedIndex]); handleSelectFeature(searchResults[selectedIndex]);
} else { } else {
const selected = displayedHistory[selectedIndex]; const selected = displayedHistory[selectedIndex];
if (selected?.feature) { if (selected) {
handleSelectFeature(selected.feature); handleSelectFeature(selected);
} }
} }
} else if (searchQuery.trim() && searchResults.length > 0) { } else if (searchQuery.trim() && searchResults.length > 0) {
@@ -171,7 +151,7 @@ export function useTopBar(): UseTopBarReturn {
searchQuery, searchQuery,
showResults, showResults,
searchResults, searchResults,
displayedHistory, recentFeatures: displayedHistory,
selectedIndex, selectedIndex,
isDashboard: currentPage === 'dashboard', isDashboard: currentPage === 'dashboard',
ThemeIcon, ThemeIcon,