refactor: 移除 Dashboard 页面及相关功能,更新路由和组件逻辑
- 移除 Dashboard 相关的组件、特性和测试代码,简化项目结构。 - 更新 PageSkeleton 组件,默认渲染工具页面骨架。 - 修改 RouterContainer 和 RouterProvider 以适应新的默认路由策略,确保用户直接访问 timestamp 页面。 - 更新测试用例,确保在新结构下的功能正常。
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
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';
|
||||
|
||||
const mockNavigateTo = vi.fn();
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'timestamp' as PageType,
|
||||
visiblePages: ['timestamp', 'jwt', 'storageCleaner'] as PageType[],
|
||||
pageOrder: ['jwt', 'timestamp', 'storageCleaner'] as PageType[],
|
||||
recentlyUsedTools: [] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: mockNavigateTo,
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
}));
|
||||
|
||||
describe('FeatureNav 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
mockRouterValue.pageOrder = ['jwt', 'timestamp', 'storageCleaner'];
|
||||
mockRouterValue.visiblePages = ['timestamp', 'jwt', 'storageCleaner'];
|
||||
});
|
||||
|
||||
it('应渲染全部可见工具图标', () => {
|
||||
render(<FeatureNav />);
|
||||
|
||||
expect(screen.getByLabelText('JWT 解析')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('时间戳')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('存储清理')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击图标应调用 navigateTo', () => {
|
||||
render(<FeatureNav />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('JWT 解析'));
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith('jwt');
|
||||
});
|
||||
|
||||
it('当前页对应项应有 active 样式与 aria-current', () => {
|
||||
render(<FeatureNav />);
|
||||
|
||||
const activeButton = screen.getByLabelText('时间戳');
|
||||
expect(activeButton).toHaveAttribute('aria-current', 'page');
|
||||
expect(activeButton).toHaveClass('bg-muted');
|
||||
expect(activeButton).toHaveClass('border-primary');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as featureConfig from '@/config/features';
|
||||
import { resolveNavFeatures } from '../resolveNavFeatures';
|
||||
|
||||
describe('resolveNavFeatures', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('应按 keys 顺序返回可见工具', () => {
|
||||
const items = resolveNavFeatures(['jwt', 'timestamp'], ['timestamp', 'jwt']);
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['jwt', 'timestamp']);
|
||||
expect(items.map((item) => item.feature.label)).toEqual(['JWT 解析', '时间戳']);
|
||||
});
|
||||
|
||||
it('缺少 themeColorKey 但有 icon 的工具仍应显示', () => {
|
||||
const originalGetFeatureByKey = featureConfig.getFeatureByKey;
|
||||
vi.spyOn(featureConfig, 'getFeatureByKey').mockImplementation((key) => {
|
||||
const feature = originalGetFeatureByKey(key);
|
||||
if (key === 'timestamp' && feature) {
|
||||
return { ...feature, themeColorKey: undefined };
|
||||
}
|
||||
return feature;
|
||||
});
|
||||
|
||||
const items = resolveNavFeatures(['timestamp'], ['timestamp']);
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['timestamp']);
|
||||
});
|
||||
|
||||
it('应过滤不在 visiblePages 中的工具', () => {
|
||||
const items = resolveNavFeatures(['jwt', 'timestamp'], ['timestamp']);
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['timestamp']);
|
||||
});
|
||||
|
||||
it('visiblePages 无匹配工具时应返回空列表', () => {
|
||||
const items = resolveNavFeatures(['jwt', 'timestamp'], []);
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('应过滤缺少 icon 的工具', () => {
|
||||
const originalGetFeatureByKey = featureConfig.getFeatureByKey;
|
||||
vi.spyOn(featureConfig, 'getFeatureByKey').mockImplementation((key) => {
|
||||
const feature = originalGetFeatureByKey(key);
|
||||
if (key === 'jwt' && feature) {
|
||||
return { ...feature, icon: undefined };
|
||||
}
|
||||
return feature;
|
||||
});
|
||||
|
||||
const items = resolveNavFeatures(['jwt', 'timestamp'], ['jwt', 'timestamp']);
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['timestamp']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
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"
|
||||
>
|
||||
{navItems.map(({ key, feature }) => {
|
||||
const Icon = feature.icon;
|
||||
const isActive = currentPage === key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
title={feature.label}
|
||||
aria-label={feature.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => navigateTo(key)}
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
|
||||
isActive && 'border-l-2 border-primary bg-muted text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getFeatureByKey, type FeatureConfig } from '@/config/features';
|
||||
import type { PageType } from '@/types/storage';
|
||||
|
||||
export interface NavFeatureItem {
|
||||
key: PageType;
|
||||
feature: FeatureConfig & { icon: NonNullable<FeatureConfig['icon']> };
|
||||
}
|
||||
|
||||
function isNavFeature(feature: FeatureConfig): feature is NavFeatureItem['feature'] {
|
||||
return feature.icon != null;
|
||||
}
|
||||
|
||||
export function resolveNavFeatures(keys: PageType[], visiblePages: PageType[]): NavFeatureItem[] {
|
||||
const visibleSet = new Set(visiblePages);
|
||||
const items: NavFeatureItem[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!visibleSet.has(key)) continue;
|
||||
|
||||
const feature = getFeatureByKey(key);
|
||||
if (!feature || !isNavFeature(feature)) continue;
|
||||
|
||||
items.push({ key, feature });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import { resolveNavFeatures, type NavFeatureItem } from './resolveNavFeatures';
|
||||
|
||||
export interface UseFeatureNavReturn {
|
||||
navItems: NavFeatureItem[];
|
||||
currentPage: PageType;
|
||||
navigateTo: (page: PageType) => void;
|
||||
}
|
||||
|
||||
export function useFeatureNav(): UseFeatureNavReturn {
|
||||
const { currentPage, pageOrder, visiblePages, navigateTo } = useRouter();
|
||||
|
||||
const navItems = useMemo(
|
||||
() => resolveNavFeatures(pageOrder, visiblePages),
|
||||
[pageOrder, visiblePages],
|
||||
);
|
||||
|
||||
return { navItems, currentPage, navigateTo };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
import TopBar from '@/layout/TopBar';
|
||||
@@ -21,13 +21,12 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
});
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
currentPage: 'timestamp' as PageType,
|
||||
visiblePages: ['timestamp', 'jwt'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
recentlyUsedTools: [] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goHome: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
@@ -51,26 +50,14 @@ describe('TopBar 组件', () => {
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar />);
|
||||
expect(screen.getByLabelText('返回首页')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
it('不应渲染返回首页按钮', () => {
|
||||
renderWithProvider(<TopBar />);
|
||||
expect(screen.queryByLabelText('返回首页')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击返回按钮时应调用 goHome', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
it('应渲染搜索输入框', () => {
|
||||
renderWithProvider(<TopBar />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('返回首页'));
|
||||
expect(mockRouterValue.goHome).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SearchDropdown from './SearchDropdown';
|
||||
import SearchInput from './SearchInput';
|
||||
import TopBarActions from './TopBarActions';
|
||||
@@ -11,7 +9,6 @@ export default function TopBar() {
|
||||
searchResults,
|
||||
recentFeatures,
|
||||
selectedIndex,
|
||||
isDashboard,
|
||||
ThemeIcon,
|
||||
themeTitle,
|
||||
showDropdown,
|
||||
@@ -22,7 +19,6 @@ export default function TopBar() {
|
||||
handleSelectFeature,
|
||||
handleKeyDown,
|
||||
cycleThemeMode,
|
||||
goHome,
|
||||
clearSearch,
|
||||
} = useTopBar();
|
||||
|
||||
@@ -30,21 +26,6 @@ export default function TopBar() {
|
||||
|
||||
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"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={goHome}
|
||||
aria-label="返回首页"
|
||||
className="h-8 w-8 shadow-sm text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="relative mx-4 max-w-md flex-1">
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface UseTopBarReturn {
|
||||
searchResults: FeatureConfig[];
|
||||
recentFeatures: FeatureConfig[];
|
||||
selectedIndex: number;
|
||||
isDashboard: boolean;
|
||||
ThemeIcon: typeof Sun;
|
||||
themeTitle: string;
|
||||
showDropdown: boolean;
|
||||
@@ -29,12 +28,11 @@ export interface UseTopBarReturn {
|
||||
handleSelectFeature: (feature: FeatureConfig) => void;
|
||||
handleKeyDown: (e: ReactKeyboardEvent) => void;
|
||||
cycleThemeMode: () => void;
|
||||
goHome: () => void;
|
||||
clearSearch: () => void;
|
||||
}
|
||||
|
||||
export function useTopBar(): UseTopBarReturn {
|
||||
const { currentPage, goHome, navigateTo } = useRouter();
|
||||
const { navigateTo } = useRouter();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -75,7 +73,6 @@ export function useTopBar(): UseTopBarReturn {
|
||||
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]);
|
||||
@@ -85,7 +82,7 @@ export function useTopBar(): UseTopBarReturn {
|
||||
return searchHistory
|
||||
.slice(0, SEARCH_HISTORY_DISPLAY)
|
||||
.map((key) => FEATURES.find((f) => f.key === key))
|
||||
.filter((feature): feature is FeatureConfig => !!feature && feature.key !== 'dashboard');
|
||||
.filter((feature): feature is FeatureConfig => !!feature);
|
||||
}, [searchHistory, searchQuery]);
|
||||
|
||||
const saveToHistory = (featureKey: string) => {
|
||||
@@ -158,7 +155,6 @@ export function useTopBar(): UseTopBarReturn {
|
||||
recentFeatures,
|
||||
selectedIndex,
|
||||
showDropdown,
|
||||
isDashboard: currentPage === 'dashboard',
|
||||
ThemeIcon,
|
||||
themeTitle,
|
||||
containerRef,
|
||||
@@ -168,7 +164,6 @@ export function useTopBar(): UseTopBarReturn {
|
||||
handleSelectFeature,
|
||||
handleKeyDown,
|
||||
cycleThemeMode,
|
||||
goHome,
|
||||
clearSearch,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user