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 (
+
+
+ {!isSearching && items.length > 0 && (
+
+ 最近搜索
+
+ )}
+ {isSearching && items.length === 0 ? (
+ - 未找到相关工具
+ ) : (
+ items.map((feature, index) => (
+
+ ))
+ )}
+
+
+ );
+}
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 (
);
}
-
-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,