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
+47 -136
View File
@@ -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 (
<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">
{!isDashboard && (
<button
<Button
type="button"
variant="outline"
size="icon"
onClick={goHome}
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" />
</button>
</Button>
)}
</div>
<div ref={containerRef} className="relative mx-4 max-w-md flex-1">
<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) => {
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"
<SearchInput
inputRef={inputRef}
searchQuery={searchQuery}
onSearchQueryChange={handleSearchQueryChange}
onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown}
onClear={clearSearch}
/>
{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>
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
<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>
<TopBarActions actions={actions} />
</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>
);
}