refactor: 重构 TopBar 组件并更新项目结构

- 删除原有的 TopBar 组件,重构为布局组件并移动至 layout/ 目录。
- 更新相关文档,反映 TopBar 组件的新位置。
- 引入新的 useTopBar 钩子以管理 TopBar 的状态和逻辑。
- 添加 TopBar 组件的单元测试,确保功能正常。
This commit is contained in:
雨霖铃
2026-06-19 09:53:29 +08:00
parent a8a1e89f8a
commit 2c901ab4fd
10 changed files with 404 additions and 325 deletions
+180
View File
@@ -0,0 +1,180 @@
import React from 'react';
import { ArrowLeft, ExternalLink, Search, X } from 'lucide-react';
import { type FeatureConfig } from '@/config/features';
import { cn } from '@/lib/utils';
import { useTopBar } from './useTopBar';
export default function TopBar() {
const {
searchQuery,
showResults,
searchResults,
displayedHistory,
selectedIndex,
isDashboard,
ThemeIcon,
themeTitle,
containerRef,
inputRef,
setSearchQuery,
setShowResults,
setSelectedIndex,
handleSelectFeature,
handleKeyDown,
cycleThemeMode,
handleOpenInTab,
goHome,
clearSearch,
} = useTopBar();
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
type="button"
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"
>
<ArrowLeft className="h-4 w-4" />
</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"
/>
{!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>
</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>
);
}