feat(TopBar): 添加搜索功能组件并优化结构
- 新增 SearchInput、SearchDropdown、SearchResultItem 和 TopBarActions 组件,增强 TopBar 的搜索功能。 - 更新 useTopBar 钩子以支持最近搜索功能,并优化搜索历史管理。 - 修改 TopBar 组件以整合新组件,提升可读性和可维护性。 - 更新 README.md,反映新增组件和功能。
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user