feat(Dashboard): 重构仪表板功能组件和逻辑
- 新增 dashboardFeatures.ts 和 useDashboard.ts 文件,封装仪表板功能的解析和状态管理逻辑。 - 更新 Dashboard 组件,使用 useDashboard 钩子简化可见工具和最近使用工具的处理。 - 添加仪表板功能的单元测试,确保功能正确性。 - 更新 README.md,移除不再使用的 ToolCard 组件说明。
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as featureConfig from '@/config/features';
|
||||
import { resolveDashboardFeatures } from '../dashboardFeatures';
|
||||
|
||||
describe('resolveDashboardFeatures', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('应按 keys 顺序返回可见工具,并排除 dashboard', () => {
|
||||
const items = resolveDashboardFeatures(['jwt', 'timestamp'], ['dashboard', '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 = resolveDashboardFeatures(['timestamp'], ['dashboard', 'timestamp']);
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['timestamp']);
|
||||
});
|
||||
|
||||
it('应过滤不在 visiblePages 中的工具', () => {
|
||||
const items = resolveDashboardFeatures(['jwt', 'timestamp'], ['dashboard', 'timestamp']);
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['timestamp']);
|
||||
});
|
||||
|
||||
it('visiblePages 无匹配工具时应返回空列表', () => {
|
||||
const items = resolveDashboardFeatures(['jwt', 'timestamp'], ['dashboard']);
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('应过滤缺少 icon 的工具', () => {
|
||||
const items = resolveDashboardFeatures(['dashboard', 'timestamp'], ['dashboard', 'timestamp']);
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['timestamp']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getFeatureByKey } from '@/config/features';
|
||||
import type { DashboardFeatureItem } from '../useDashboard';
|
||||
import Index from '../index';
|
||||
|
||||
const mockNavigateTo = vi.fn();
|
||||
const mockUseDashboard = vi.fn();
|
||||
|
||||
vi.mock('../useDashboard', () => ({
|
||||
useDashboard: () => mockUseDashboard(),
|
||||
}));
|
||||
|
||||
function buildFeatureItem(key: 'timestamp' | 'jwt'): DashboardFeatureItem {
|
||||
const feature = getFeatureByKey(key)!;
|
||||
return {
|
||||
key,
|
||||
feature: feature as DashboardFeatureItem['feature'],
|
||||
};
|
||||
}
|
||||
|
||||
describe('Dashboard 页面', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseDashboard.mockReturnValue({
|
||||
visibleFeatures: [buildFeatureItem('timestamp'), buildFeatureItem('jwt')],
|
||||
recentFeatures: [],
|
||||
showRecent: false,
|
||||
navigateTo: mockNavigateTo,
|
||||
});
|
||||
});
|
||||
|
||||
it('应渲染全部工具网格', () => {
|
||||
render(<Index />);
|
||||
|
||||
expect(screen.getByText('全部工具')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '时间戳' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'JWT 解析' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击工具卡片应触发 navigateTo', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Index />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '时间戳' }));
|
||||
|
||||
expect(mockNavigateTo).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith('timestamp');
|
||||
});
|
||||
|
||||
it('有最近使用数据时应显示最近使用区块', () => {
|
||||
mockUseDashboard.mockReturnValue({
|
||||
visibleFeatures: [buildFeatureItem('timestamp')],
|
||||
recentFeatures: [buildFeatureItem('jwt')],
|
||||
showRecent: true,
|
||||
navigateTo: mockNavigateTo,
|
||||
});
|
||||
|
||||
render(<Index />);
|
||||
|
||||
expect(screen.getByText('最近使用')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'JWT 解析' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无最近使用数据时不应显示最近使用区块', () => {
|
||||
render(<Index />);
|
||||
|
||||
expect(screen.queryByText('最近使用')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击最近使用按钮应触发 navigateTo', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseDashboard.mockReturnValue({
|
||||
visibleFeatures: [buildFeatureItem('timestamp')],
|
||||
recentFeatures: [buildFeatureItem('jwt')],
|
||||
showRecent: true,
|
||||
navigateTo: mockNavigateTo,
|
||||
});
|
||||
|
||||
render(<Index />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'JWT 解析' }));
|
||||
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith('jwt');
|
||||
});
|
||||
|
||||
it('无可见工具时应显示空状态', () => {
|
||||
mockUseDashboard.mockReturnValue({
|
||||
visibleFeatures: [],
|
||||
recentFeatures: [],
|
||||
showRecent: false,
|
||||
navigateTo: mockNavigateTo,
|
||||
});
|
||||
|
||||
render(<Index />);
|
||||
|
||||
expect(screen.getByText('没有可用的工具')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { useDashboard } from '../useDashboard';
|
||||
import type { PageType } from '@/types/storage';
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigateTo = vi.fn();
|
||||
|
||||
function mockRouterState(
|
||||
overrides: {
|
||||
visiblePages?: PageType[];
|
||||
pageOrder?: PageType[];
|
||||
recentlyUsedTools?: PageType[];
|
||||
} = {},
|
||||
) {
|
||||
vi.mocked(useRouter).mockReturnValue({
|
||||
currentPage: 'dashboard',
|
||||
visiblePages: overrides.visiblePages ?? ['dashboard', 'timestamp', 'jwt'],
|
||||
pageOrder: overrides.pageOrder ?? ['jwt', 'timestamp'],
|
||||
recentlyUsedTools: overrides.recentlyUsedTools ?? [],
|
||||
isLoaded: true,
|
||||
navigateTo: mockNavigateTo,
|
||||
goHome: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('应透传 router 中的 navigateTo', () => {
|
||||
mockRouterState();
|
||||
|
||||
const { result } = renderHook(() => useDashboard());
|
||||
|
||||
expect(result.current.navigateTo).toBe(mockNavigateTo);
|
||||
});
|
||||
|
||||
it('recentlyUsedTools 有可见工具时应显示最近使用', () => {
|
||||
mockRouterState({
|
||||
visiblePages: ['dashboard', 'timestamp', 'jwt'],
|
||||
recentlyUsedTools: ['timestamp', 'jwt'],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDashboard());
|
||||
|
||||
expect(result.current.showRecent).toBe(true);
|
||||
expect(result.current.recentFeatures.map((item) => item.key)).toEqual(['timestamp', 'jwt']);
|
||||
});
|
||||
|
||||
it('recentlyUsedTools 为空时不应显示最近使用', () => {
|
||||
mockRouterState({ recentlyUsedTools: [] });
|
||||
|
||||
const { result } = renderHook(() => useDashboard());
|
||||
|
||||
expect(result.current.showRecent).toBe(false);
|
||||
expect(result.current.recentFeatures).toEqual([]);
|
||||
});
|
||||
|
||||
it('最近使用中的不可见工具应被过滤', () => {
|
||||
mockRouterState({
|
||||
visiblePages: ['dashboard', 'timestamp'],
|
||||
recentlyUsedTools: ['jwt', 'timestamp'],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDashboard());
|
||||
|
||||
expect(result.current.showRecent).toBe(true);
|
||||
expect(result.current.recentFeatures.map((item) => item.key)).toEqual(['timestamp']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getFeatureByKey, type FeatureConfig } from '@/config/features';
|
||||
import type { PageType } from '@/types/storage';
|
||||
|
||||
export type DashboardFeature = FeatureConfig & {
|
||||
icon: NonNullable<FeatureConfig['icon']>;
|
||||
};
|
||||
|
||||
export interface DashboardFeatureItem {
|
||||
key: PageType;
|
||||
feature: DashboardFeature;
|
||||
}
|
||||
|
||||
function isDashboardFeature(feature: FeatureConfig): feature is DashboardFeature {
|
||||
return feature.icon != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将页面 key 列表解析为 Dashboard 可展示的工具项。
|
||||
* 过滤条件:在 visiblePages 中且 feature 配置了 icon。
|
||||
*/
|
||||
export function resolveDashboardFeatures(
|
||||
keys: PageType[],
|
||||
visiblePages: PageType[],
|
||||
): DashboardFeatureItem[] {
|
||||
const visibleSet = new Set(visiblePages);
|
||||
const items: DashboardFeatureItem[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!visibleSet.has(key)) continue;
|
||||
|
||||
const feature = getFeatureByKey(key);
|
||||
if (!feature || !isDashboardFeature(feature)) continue;
|
||||
|
||||
items.push({ key, feature });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -1,28 +1,11 @@
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { getFeatureByKey } from '@/config/features';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDashboard } from './useDashboard';
|
||||
|
||||
export default function Index() {
|
||||
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
|
||||
|
||||
const visibleSet = new Set<string>(visiblePages);
|
||||
|
||||
const visibleFeatures = pageOrder
|
||||
.filter((key) => visibleSet.has(key))
|
||||
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||
|
||||
const recentFeatures = recentlyUsedTools
|
||||
.filter((key) => visibleSet.has(key))
|
||||
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||
|
||||
const showRecent = recentFeatures.length > 0;
|
||||
const { visibleFeatures, recentFeatures, showRecent, navigateTo } = useDashboard();
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4 p-3.5 w-full h-auto select-none')}>
|
||||
{/* 最近使用 */}
|
||||
{showRecent && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||
@@ -30,12 +13,12 @@ export default function Index() {
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentFeatures.map(({ key, feature }) => {
|
||||
const IconComponent = feature!.icon!;
|
||||
const IconComponent = feature.icon;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => navigateTo(key as PageType)}
|
||||
onClick={() => navigateTo(key)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium',
|
||||
'border border-border/60 bg-card text-card-foreground',
|
||||
@@ -44,7 +27,7 @@ export default function Index() {
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
{feature!.label}
|
||||
{feature.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -52,40 +35,45 @@ export default function Index() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部工具 — 紧凑 Grid */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||
{'全部工具'}
|
||||
</h3>
|
||||
<div className={cn('grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2')}>
|
||||
{visibleFeatures.map(({ key, feature }) => {
|
||||
const IconComponent = feature!.icon!;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => navigateTo(key as PageType)}
|
||||
className={cn(
|
||||
'group flex flex-col items-center justify-center gap-1.5',
|
||||
'py-3 px-2 rounded-xl border border-border/50 bg-card',
|
||||
'hover:bg-muted/40 hover:border-primary/30',
|
||||
'transition-colors cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<IconComponent
|
||||
{visibleFeatures.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{'没有可用的工具'}</p>
|
||||
) : (
|
||||
<div
|
||||
className={cn('grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2')}
|
||||
>
|
||||
{visibleFeatures.map(({ key, feature }) => {
|
||||
const IconComponent = feature.icon;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => navigateTo(key)}
|
||||
className={cn(
|
||||
'h-5 w-5 text-muted-foreground/70',
|
||||
'group-hover:text-foreground',
|
||||
'transition-colors',
|
||||
'group flex flex-col items-center justify-center gap-1.5',
|
||||
'py-3 px-2 rounded-xl border border-border/50 bg-card',
|
||||
'hover:bg-muted/40 hover:border-primary/30',
|
||||
'transition-colors cursor-pointer',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-muted-foreground/80 group-hover:text-foreground leading-tight text-center truncate w-full transition-colors">
|
||||
{feature!.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
>
|
||||
<IconComponent
|
||||
className={cn(
|
||||
'h-5 w-5 text-muted-foreground/70',
|
||||
'group-hover:text-foreground',
|
||||
'transition-colors',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-muted-foreground/80 group-hover:text-foreground leading-tight text-center truncate w-full transition-colors">
|
||||
{feature.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import { resolveDashboardFeatures, type DashboardFeatureItem } from './dashboardFeatures';
|
||||
|
||||
export type { DashboardFeatureItem } from './dashboardFeatures';
|
||||
|
||||
export interface UseDashboardReturn {
|
||||
visibleFeatures: DashboardFeatureItem[];
|
||||
recentFeatures: DashboardFeatureItem[];
|
||||
showRecent: boolean;
|
||||
navigateTo: (page: PageType) => void;
|
||||
}
|
||||
|
||||
export function useDashboard(): UseDashboardReturn {
|
||||
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
|
||||
|
||||
const visibleFeatures = useMemo(
|
||||
() => resolveDashboardFeatures(pageOrder, visiblePages),
|
||||
[pageOrder, visiblePages],
|
||||
);
|
||||
|
||||
const recentFeatures = useMemo(
|
||||
() => resolveDashboardFeatures(recentlyUsedTools, visiblePages),
|
||||
[recentlyUsedTools, visiblePages],
|
||||
);
|
||||
|
||||
const showRecent = recentFeatures.length > 0;
|
||||
|
||||
return { visibleFeatures, recentFeatures, showRecent, navigateTo };
|
||||
}
|
||||
Reference in New Issue
Block a user