refactor: 移除 TopBar 组件,重构功能导航并引入主题切换按钮

- 删除 TopBar 及其相关组件和逻辑,简化布局结构。
- 新增 FeatureNav 组件,包含功能导航和主题切换按钮。
- 更新 README.md,反映新的组件结构和功能。
- 修改测试用例,确保新结构下的功能正常。
This commit is contained in:
2026-07-05 22:30:13 +08:00
parent 43038c5edb
commit b487f71512
17 changed files with 115 additions and 541 deletions
+1 -3
View File
@@ -1,5 +1,4 @@
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/layout/TopBar';
import FeatureNav from '@/layout/FeatureNav';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
@@ -13,8 +12,7 @@ export default function App() {
pageOrderKey="app/popupPageOrder"
>
<div className="flex w-[450px] max-w-[450px] min-w-[450px] h-[600px] min-h-[600px] overflow-hidden bg-background">
<div className="flex flex-col flex-1 min-w-0">
<TopBar />
<div className="flex flex-1 min-w-0 flex-col">
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
@@ -0,0 +1,20 @@
import { Button } from '@/components/ui/button';
import { useThemeToggle } from './useThemeToggle';
export default function ThemeToggleButton() {
const { ThemeIcon, themeTitle, cycleThemeMode } = useThemeToggle();
return (
<Button
type="button"
variant="ghost"
size="icon"
onClick={cycleThemeMode}
title={themeTitle}
aria-label={themeTitle}
className="h-9 w-9 text-muted-foreground hover:text-foreground"
>
<ThemeIcon className="h-4 w-4" aria-hidden="true" />
</Button>
);
}
+31 -3
View File
@@ -2,6 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import type { PageType } from '@/types/storage';
import FeatureNav from '@/layout/FeatureNav';
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
const mockNavigateTo = vi.fn();
@@ -28,8 +43,15 @@ describe('FeatureNav 组件', () => {
mockRouterValue.visiblePages = ['timestamp', 'jwt', 'storageCleaner'];
});
const renderFeatureNav = () =>
render(
<ThemeModeProvider>
<FeatureNav />
</ThemeModeProvider>,
);
it('应渲染全部可见工具图标', () => {
render(<FeatureNav />);
renderFeatureNav();
expect(screen.getByLabelText('JWT 解析')).toBeInTheDocument();
expect(screen.getByLabelText('时间戳')).toBeInTheDocument();
@@ -37,18 +59,24 @@ describe('FeatureNav 组件', () => {
});
it('点击图标应调用 navigateTo', () => {
render(<FeatureNav />);
renderFeatureNav();
fireEvent.click(screen.getByLabelText('JWT 解析'));
expect(mockNavigateTo).toHaveBeenCalledWith('jwt');
});
it('当前页对应项应有 active 样式与 aria-current', () => {
render(<FeatureNav />);
renderFeatureNav();
const activeButton = screen.getByLabelText('时间戳');
expect(activeButton).toHaveAttribute('aria-current', 'page');
expect(activeButton).toHaveClass('bg-muted');
expect(activeButton).toHaveClass('border-primary');
});
it('应渲染主题切换按钮', () => {
renderFeatureNav();
expect(screen.getByLabelText(/切换到/)).toBeInTheDocument();
});
});
+8 -4
View File
@@ -1,14 +1,13 @@
import { cn } from '@/lib/utils';
import ThemeToggleButton from './ThemeToggleButton';
import { useFeatureNav } from './useFeatureNav';
export default function FeatureNav() {
const { navItems, currentPage, navigateTo } = useFeatureNav();
return (
<nav
aria-label="功能导航"
className="flex w-12 shrink-0 flex-col items-center gap-1 overflow-y-auto border-l border-border py-2"
>
<nav aria-label="功能导航" className="flex w-12 shrink-0 flex-col border-l border-border py-2">
<div className="flex flex-1 flex-col items-center gap-1 overflow-y-auto">
{navItems.map(({ key, feature }) => {
const Icon = feature.icon;
const isActive = currentPage === key;
@@ -30,6 +29,11 @@ export default function FeatureNav() {
</button>
);
})}
</div>
<div className="mt-1 flex shrink-0 items-center justify-center border-t border-border pt-1">
<ThemeToggleButton />
</div>
</nav>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { Monitor, Moon, Sun } from 'lucide-react';
import { useThemeMode } from '@/providers/ThemeModeProvider';
export interface UseThemeToggleReturn {
ThemeIcon: typeof Sun;
themeTitle: string;
cycleThemeMode: () => void;
}
export function useThemeToggle(): UseThemeToggleReturn {
const { mode, setMode } = useThemeMode();
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' ? '切换到系统模式' : '切换到浅色模式';
return { ThemeIcon, themeTitle, cycleThemeMode };
}
+8 -10
View File
@@ -5,22 +5,20 @@
## 组件列表
| 目录 | 用途 |
| --------- | -------------------------------------------------------------- |
| `TopBar/` | 顶部导航栏:搜索(含历史记录)、主题切换、返回导航、标签页打开 |
| ------------- | ------------------------------------------ |
| `FeatureNav/` | 右侧功能导航栏:工具图标切换、底部主题切换 |
## 目录结构
遵循与 `pages/` 相同的 UI + Hook 分离模式:
```
layout/TopBar/
├── index.tsx # 布局入口
├── useTopBar.ts # 业务逻辑 Hook
├── constants.ts # 常量
├── SearchInput.tsx # 搜索输入框
├── SearchDropdown.tsx # 搜索结果/历史下拉面板
├── SearchResultItem.tsx # 单条搜索结果项
├── TopBarActions.tsx # 右侧操作按钮组
layout/FeatureNav/
├── index.tsx # 垂直图标导航 + 底部主题按钮
├── useFeatureNav.ts # 读路由状态,暴露 navItems / navigateTo
├── useThemeToggle.ts # 主题模式循环切换逻辑
├── ThemeToggleButton.tsx # 主题切换按钮
├── resolveNavFeatures.ts # 功能列表解析
└── __tests__/
└── index.test.tsx
```
-45
View File
@@ -1,45 +0,0 @@
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();
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 fade-in-slide-top-2">
<ul role="listbox" className="p-1.5">
{!isSearching && items.length > 0 && (
<li className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
</li>
)}
{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>
);
}
-55
View File
@@ -1,55 +0,0 @@
import { type KeyboardEvent, type RefObject } from 'react';
import { Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { getSearchShortcutLabel } from './constants';
interface SearchInputProps {
inputRef: RefObject<HTMLInputElement | null>;
searchQuery: string;
onSearchQueryChange: (value: string) => void;
onFocus: () => void;
onKeyDown: (e: 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}
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 ? (
<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>
) : (
<kbd className="pointer-events-none absolute right-3 top-1/2 hidden h-5 -translate-y-1/2 items-center rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 sm:inline-flex">
{getSearchShortcutLabel()}
</kbd>
)}
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { type FeatureConfig } from '@/config/features';
import { cn } from '@/lib/utils';
interface SearchResultItemProps {
feature: FeatureConfig;
selected: boolean;
onSelect: (feature: FeatureConfig) => void;
}
export default 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>
);
}
-34
View File
@@ -1,34 +0,0 @@
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,37 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getSearchShortcutLabel } from '@/layout/TopBar/constants';
describe('getSearchShortcutLabel', () => {
const originalUserAgent = navigator.userAgent;
afterEach(() => {
vi.stubGlobal('navigator', { ...navigator, userAgent: originalUserAgent });
});
it('macOS 应显示 ⌘K', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
expect(getSearchShortcutLabel()).toBe('⌘K');
});
it('Windows 应显示 Ctrl+K', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
expect(getSearchShortcutLabel()).toBe('Ctrl+K');
});
it('Linux 应显示 Ctrl+K', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
expect(getSearchShortcutLabel()).toBe('Ctrl+K');
});
});
@@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { PageType } from '@/types/storage';
import React from 'react';
import TopBar from '@/layout/TopBar';
import { RouterProvider } from '@/providers/RouterProvider';
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
const mockRouterValue = {
currentPage: 'timestamp' as PageType,
visiblePages: ['timestamp', 'jwt'] as PageType[],
pageOrder: ['timestamp'] as PageType[],
recentlyUsedTools: [] as PageType[],
isLoaded: true,
navigateTo: vi.fn(),
setVisiblePages: vi.fn(),
setPageOrder: vi.fn(),
};
vi.mock('@/providers/RouterProvider', () => ({
useRouter: () => mockRouterValue,
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
}));
describe('TopBar 组件', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const renderWithProvider = (ui: React.ReactElement) => {
return render(
<ThemeModeProvider>
<RouterProvider>{ui}</RouterProvider>
</ThemeModeProvider>,
);
};
describe('渲染测试', () => {
it('不应渲染返回首页按钮', () => {
renderWithProvider(<TopBar />);
expect(screen.queryByLabelText('返回首页')).not.toBeInTheDocument();
});
it('应渲染搜索输入框', () => {
renderWithProvider(<TopBar />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
});
});
-9
View File
@@ -1,9 +0,0 @@
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');
export function getSearchShortcutLabel(): string {
return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
}
-52
View File
@@ -1,52 +0,0 @@
import SearchDropdown from './SearchDropdown';
import SearchInput from './SearchInput';
import TopBarActions from './TopBarActions';
import { useTopBar } from './useTopBar';
export default function TopBar() {
const {
searchQuery,
searchResults,
recentFeatures,
selectedIndex,
ThemeIcon,
themeTitle,
showDropdown,
containerRef,
inputRef,
handleSearchQueryChange,
handleSearchFocus,
handleSelectFeature,
handleKeyDown,
cycleThemeMode,
clearSearch,
} = useTopBar();
const actions = [{ id: 'theme', icon: ThemeIcon, title: themeTitle, onClick: cycleThemeMode }];
return (
<header className="relative z-50 flex h-14 items-center justify-between border-b border-border bg-background px-4">
<div ref={containerRef} className="relative mx-4 max-w-md flex-1">
<SearchInput
inputRef={inputRef}
searchQuery={searchQuery}
onSearchQueryChange={handleSearchQueryChange}
onFocus={handleSearchFocus}
onKeyDown={handleKeyDown}
onClear={clearSearch}
/>
{showDropdown && (
<SearchDropdown
searchQuery={searchQuery}
searchResults={searchResults}
recentFeatures={recentFeatures}
selectedIndex={selectedIndex}
onSelect={handleSelectFeature}
/>
)}
</div>
<TopBarActions actions={actions} />
</header>
);
}
-169
View File
@@ -1,169 +0,0 @@
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
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 { useStorageState } from '@/utils/useStorageState';
import { isSearchHistory, SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants';
export interface UseTopBarReturn {
searchQuery: string;
searchResults: FeatureConfig[];
recentFeatures: FeatureConfig[];
selectedIndex: number;
ThemeIcon: typeof Sun;
themeTitle: string;
showDropdown: boolean;
containerRef: RefObject<HTMLDivElement | null>;
inputRef: RefObject<HTMLInputElement | null>;
handleSearchQueryChange: (value: string) => void;
handleSearchFocus: () => void;
handleSelectFeature: (feature: FeatureConfig) => void;
handleKeyDown: (e: ReactKeyboardEvent) => void;
cycleThemeMode: () => void;
clearSearch: () => void;
}
export function useTopBar(): UseTopBarReturn {
const { navigateTo } = useRouter();
const { mode, setMode } = useThemeMode();
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
const [searchHistory, setSearchHistory] = useStorageState(
'app/searchHistory',
[],
isSearchHistory,
);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
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);
}, []);
const searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return [];
return FEATURES.filter((f) => {
return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query);
});
}, [searchQuery]);
const recentFeatures = useMemo(() => {
if (searchQuery.trim()) return [];
return searchHistory
.slice(0, SEARCH_HISTORY_DISPLAY)
.map((key) => FEATURES.find((f) => f.key === key))
.filter((feature): feature is FeatureConfig => !!feature);
}, [searchHistory, searchQuery]);
const saveToHistory = (featureKey: string) => {
setSearchHistory((prev) =>
[featureKey, ...prev.filter((h) => h !== featureKey)].slice(0, SEARCH_HISTORY_LIMIT),
);
};
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: ReactKeyboardEvent) => {
const isSearching = !!searchQuery.trim();
const items = isSearching ? searchResults : recentFeatures;
const totalItems = items.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();
let feature: FeatureConfig | undefined;
if (selectedIndex >= 0 && selectedIndex < totalItems) {
feature = items[selectedIndex];
} else if (isSearching && searchResults.length > 0) {
feature = searchResults[0];
}
if (feature) handleSelectFeature(feature);
} else if (e.key === 'Escape') {
setShowResults(false);
inputRef.current?.blur();
}
};
const clearSearch = () => {
setSearchQuery('');
setSelectedIndex(-1);
};
const handleSearchQueryChange = (value: string) => {
setSearchQuery(value);
setShowResults(true);
setSelectedIndex(-1);
};
const handleSearchFocus = () => setShowResults(true);
const showDropdown = showResults && (!!searchQuery.trim() || recentFeatures.length > 0);
return {
searchQuery,
searchResults,
recentFeatures,
selectedIndex,
showDropdown,
ThemeIcon,
themeTitle,
containerRef,
inputRef,
handleSearchQueryChange,
handleSearchFocus,
handleSelectFeature,
handleKeyDown,
cycleThemeMode,
clearSearch,
};
}
-2
View File
@@ -87,8 +87,6 @@ export interface StorageSchema {
'qrCode/qrExpanded': boolean;
/** 二维码工具中 URL 部分是否展开 */
'qrCode/urlExpanded': boolean;
/** 搜索历史记录 */
'app/searchHistory': string[];
/** JSON 工具页面当前子模式 */
'jsonTools/pageMode': JsonToolsPageMode;
/** Base64 转换器页面当前子模式 */
+2 -4
View File
@@ -169,13 +169,11 @@ describe('useStorageState', () => {
});
it('加载失败时不应把快照默认值写回 Chrome Storage', async () => {
localStorage.setItem('snapshot/app/searchHistory', JSON.stringify([]));
localStorage.setItem('snapshot/qrCode/urlExpanded', JSON.stringify(true));
(storageUtil.get as any).mockRejectedValue(new Error('Storage read failed'));
renderHook(() =>
useStorageState('app/searchHistory', [], (val): val is string[] => Array.isArray(val)),
);
renderHook(() => useStorageState('qrCode/urlExpanded', false));
await waitFor(() => {
expect(storageUtil.set).not.toHaveBeenCalled();