refactor: 重构 TopBar 组件并更新项目结构
- 删除原有的 TopBar 组件,重构为布局组件并移动至 layout/ 目录。 - 更新相关文档,反映 TopBar 组件的新位置。 - 引入新的 useTopBar 钩子以管理 TopBar 的状态和逻辑。 - 添加 TopBar 组件的单元测试,确保功能正常。
This commit is contained in:
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
| 组件 | 用途 |
|
| 组件 | 用途 |
|
||||||
| ----------------------- | ------------------------------------------------------------------------------ |
|
| ----------------------- | ------------------------------------------------------------------------------ |
|
||||||
| `TopBar.tsx` | 顶部导航栏,集成搜索(含历史记录)、主题切换、语言切换、返回导航 |
|
|
||||||
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
||||||
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
||||||
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
|
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react';
|
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
|
||||||
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
|
||||||
import { FeatureConfig, FEATURES } from '@/config/features';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import { openExtensionPage } from '@/utils/chromeTabs';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const SEARCH_HISTORY_LIMIT = 10;
|
|
||||||
const SEARCH_HISTORY_DISPLAY = 5;
|
|
||||||
|
|
||||||
export default function TopBar() {
|
|
||||||
const { currentPage, goHome, navigateTo } = useRouter();
|
|
||||||
const { mode, setMode } = useThemeMode();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [showResults, setShowResults] = useState(false);
|
|
||||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleOpenInTab = async () => {
|
|
||||||
await openExtensionPage('popup.html', { mode: 'tab' });
|
|
||||||
window.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
||||||
setShowResults(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Cmd/Ctrl+K 快捷键聚焦搜索框
|
|
||||||
useEffect(() => {
|
|
||||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
inputRef.current?.focus();
|
|
||||||
setShowResults(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
storageUtil
|
|
||||||
.get('app/searchHistory', [])
|
|
||||||
.then((history) => {
|
|
||||||
if (history) setSearchHistory(history);
|
|
||||||
})
|
|
||||||
.catch((err) => console.error('加载搜索历史失败:', err));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
|
||||||
if (!query) return [];
|
|
||||||
return FEATURES.filter((f) => {
|
|
||||||
if (f.key === 'dashboard') return false;
|
|
||||||
return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query);
|
|
||||||
});
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
const displayedHistory = useMemo(() => {
|
|
||||||
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');
|
|
||||||
}, [searchHistory, searchQuery]);
|
|
||||||
|
|
||||||
const saveToHistory = async (featureKey: string) => {
|
|
||||||
if (!featureKey.trim()) return;
|
|
||||||
const nextHistory = [featureKey, ...searchHistory.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) => {
|
|
||||||
navigateTo(feature.key);
|
|
||||||
saveToHistory(feature.key);
|
|
||||||
setSearchQuery('');
|
|
||||||
setShowResults(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleThemeMode = () => {
|
|
||||||
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
|
|
||||||
setMode(nextMap[mode]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
handleSelectFeature(searchResults[selectedIndex]);
|
|
||||||
} else {
|
|
||||||
const selected = displayedHistory[selectedIndex];
|
|
||||||
if (selected?.feature) {
|
|
||||||
handleSelectFeature(selected.feature);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (searchQuery.trim() && searchResults.length > 0) {
|
|
||||||
handleSelectFeature(searchResults[0]);
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setShowResults(false);
|
|
||||||
inputRef.current?.blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDashboard = currentPage === 'dashboard';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
|
|
||||||
{/* 左侧:返回按钮区 */}
|
|
||||||
<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="flex-1 mx-4 max-w-md relative">
|
|
||||||
<div className="relative group">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 group-focus-within:text-muted-foreground transition-colors pointer-events-none" />
|
|
||||||
<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="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
|
|
||||||
/>
|
|
||||||
{!searchQuery && (
|
|
||||||
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 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 pointer-events-none">
|
|
||||||
⌘K
|
|
||||||
</kbd>
|
|
||||||
)}
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('');
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
}}
|
|
||||||
aria-label={'清除搜索'}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 动态联想结果卡片 */}
|
|
||||||
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1.5 bg-popover text-popover-foreground rounded-lg shadow-lg border border-border max-h-80 overflow-y-auto z-50 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) => (
|
|
||||||
<li
|
|
||||||
key={feature.key}
|
|
||||||
role="option"
|
|
||||||
aria-selected={selectedIndex === index}
|
|
||||||
onClick={() => handleSelectFeature(feature)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-sm transition-colors',
|
|
||||||
selectedIndex === index
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'hover:bg-muted/60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'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="flex-1 min-w-0">
|
|
||||||
<p className="font-medium text-foreground truncate">{feature.label}</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
|
||||||
{feature.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<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 tracking-wider text-muted-foreground/60 uppercase">
|
|
||||||
{'最近搜索'}
|
|
||||||
</div>
|
|
||||||
{displayedHistory.map((item, index) => (
|
|
||||||
<li
|
|
||||||
key={item.key}
|
|
||||||
role="option"
|
|
||||||
aria-selected={selectedIndex === index}
|
|
||||||
onClick={() => item.feature && handleSelectFeature(item.feature)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm transition-colors',
|
|
||||||
selectedIndex === index
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'hover:bg-muted/60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
|
||||||
'bg-muted/80 text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.feature?.icon && <item.feature.icon className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium text-foreground truncate">
|
|
||||||
{item.feature?.label}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
|
||||||
{item.feature?.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧:操作区 */}
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<IconButton
|
|
||||||
onClick={cycleThemeMode}
|
|
||||||
title={
|
|
||||||
mode === 'light'
|
|
||||||
? '切换到深色模式'
|
|
||||||
: mode === 'dark'
|
|
||||||
? '切换到系统模式'
|
|
||||||
: '切换到浅色模式'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ThemeIcon className="h-4 w-4" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton onClick={handleOpenInTab} title={'在标签页打开'}>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,10 +17,10 @@ WXT 框架要求的扩展生命周期入口点,对应 Chrome Extension 的各
|
|||||||
Popup 弹窗页面(点击扩展图标弹出)。
|
Popup 弹窗页面(点击扩展图标弹出)。
|
||||||
|
|
||||||
| 文件 | 用途 |
|
| 文件 | 用途 |
|
||||||
| ------------ | ------------------------------------------------------------------------------ |
|
| ------------ | ------------------------------------------------------------------------------------- |
|
||||||
| `index.html` | HTML 入口 |
|
| `index.html` | HTML 入口 |
|
||||||
| `main.tsx` | React 挂载点 |
|
| `main.tsx` | React 挂载点 |
|
||||||
| `App.tsx` | 根组件,组装 `RouterProvider` + `TopBar` + `ErrorBoundary` + `RouterContainer` |
|
| `App.tsx` | 根组件,组装 `RouterProvider` + `layout/TopBar` + `ErrorBoundary` + `RouterContainer` |
|
||||||
|
|
||||||
### sidepanel/
|
### sidepanel/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import RouterProvider from '@/providers/RouterProvider';
|
import RouterProvider from '@/providers/RouterProvider';
|
||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/layout/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { getEntryPointType } from '@/config/features';
|
import { getEntryPointType } from '@/config/features';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import RouterProvider from '@/providers/RouterProvider';
|
import RouterProvider from '@/providers/RouterProvider';
|
||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/layout/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# layout/
|
||||||
|
|
||||||
|
应用壳层布局组件目录,存放与扩展入口(popup / sidepanel / tab)绑定的布局 UI,与 `components/` 中的通用可复用组件区分。
|
||||||
|
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
| 目录 | 用途 |
|
||||||
|
| --------- | -------------------------------------------------------------- |
|
||||||
|
| `TopBar/` | 顶部导航栏:搜索(含历史记录)、主题切换、返回导航、标签页打开 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
遵循与 `pages/` 相同的 UI + Hook 分离模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
layout/TopBar/
|
||||||
|
├── index.tsx # 布局 UI
|
||||||
|
├── useTopBar.ts # 业务逻辑 Hook
|
||||||
|
├── constants.ts # 常量
|
||||||
|
└── __tests__/
|
||||||
|
└── index.test.tsx
|
||||||
|
```
|
||||||
+2
-2
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/layout/TopBar';
|
||||||
import { RouterProvider } from '@/providers/RouterProvider';
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
|
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ describe('TopBar 组件', () => {
|
|||||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||||
mockRouterValue.currentPage = 'dashboard';
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
renderWithProvider(<TopBar />);
|
renderWithProvider(<TopBar />);
|
||||||
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText('返回首页')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const SEARCH_HISTORY_LIMIT = 10;
|
||||||
|
export const SEARCH_HISTORY_DISPLAY = 5;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||||
|
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 { openExtensionPage } from '@/utils/chromeTabs';
|
||||||
|
import { SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants';
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
key: string;
|
||||||
|
feature?: FeatureConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTopBarReturn {
|
||||||
|
searchQuery: string;
|
||||||
|
showResults: boolean;
|
||||||
|
searchResults: FeatureConfig[];
|
||||||
|
displayedHistory: HistoryItem[];
|
||||||
|
selectedIndex: number;
|
||||||
|
isDashboard: boolean;
|
||||||
|
ThemeIcon: typeof Sun;
|
||||||
|
themeTitle: string;
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
setSearchQuery: (value: string) => void;
|
||||||
|
setShowResults: (value: boolean) => void;
|
||||||
|
setSelectedIndex: (value: number | ((prev: number) => number)) => void;
|
||||||
|
handleSelectFeature: (feature: FeatureConfig) => void;
|
||||||
|
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
cycleThemeMode: () => void;
|
||||||
|
handleOpenInTab: () => Promise<void>;
|
||||||
|
goHome: () => void;
|
||||||
|
clearSearch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTopBar(): UseTopBarReturn {
|
||||||
|
const { currentPage, goHome, navigateTo } = useRouter();
|
||||||
|
const { mode, setMode } = useThemeMode();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleOpenInTab = async () => {
|
||||||
|
await openExtensionPage('popup.html', { mode: 'tab' });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowResults(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
setShowResults(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('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 query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) return [];
|
||||||
|
return FEATURES.filter((f) => {
|
||||||
|
if (f.key === 'dashboard') return false;
|
||||||
|
return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const displayedHistory = useMemo(() => {
|
||||||
|
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');
|
||||||
|
}, [searchHistory, searchQuery]);
|
||||||
|
|
||||||
|
const saveToHistory = async (featureKey: string) => {
|
||||||
|
if (!featureKey.trim()) return;
|
||||||
|
const nextHistory = [featureKey, ...searchHistory.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) => {
|
||||||
|
navigateTo(feature.key);
|
||||||
|
saveToHistory(feature.key);
|
||||||
|
setSearchQuery('');
|
||||||
|
setShowResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleThemeMode = () => {
|
||||||
|
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
|
||||||
|
setMode(nextMap[mode]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
|
||||||
|
|
||||||
|
const themeTitle =
|
||||||
|
mode === 'light' ? '切换到深色模式' : mode === 'dark' ? '切换到系统模式' : '切换到浅色模式';
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
handleSelectFeature(searchResults[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
const selected = displayedHistory[selectedIndex];
|
||||||
|
if (selected?.feature) {
|
||||||
|
handleSelectFeature(selected.feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (searchQuery.trim() && searchResults.length > 0) {
|
||||||
|
handleSelectFeature(searchResults[0]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowResults(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery,
|
||||||
|
showResults,
|
||||||
|
searchResults,
|
||||||
|
displayedHistory,
|
||||||
|
selectedIndex,
|
||||||
|
isDashboard: currentPage === 'dashboard',
|
||||||
|
ThemeIcon,
|
||||||
|
themeTitle,
|
||||||
|
containerRef,
|
||||||
|
inputRef,
|
||||||
|
setSearchQuery,
|
||||||
|
setShowResults,
|
||||||
|
setSelectedIndex,
|
||||||
|
handleSelectFeature,
|
||||||
|
handleKeyDown,
|
||||||
|
cycleThemeMode,
|
||||||
|
handleOpenInTab,
|
||||||
|
goHome,
|
||||||
|
clearSearch,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user