From 9097c48b1f89c1f2646c860bbe7896483b3a7d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Fri, 19 Jun 2026 09:26:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(Dashboard):=20=E9=87=8D=E6=9E=84=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E6=9D=BF=E5=8A=9F=E8=83=BD=E7=BB=84=E4=BB=B6=E5=92=8C?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 dashboardFeatures.ts 和 useDashboard.ts 文件,封装仪表板功能的解析和状态管理逻辑。 - 更新 Dashboard 组件,使用 useDashboard 钩子简化可见工具和最近使用工具的处理。 - 添加仪表板功能的单元测试,确保功能正确性。 - 更新 README.md,移除不再使用的 ToolCard 组件说明。 --- .../__tests__/dashboardFeatures.test.ts | 49 +++++++++ src/pages/Dashboard/__tests__/index.test.tsx | 100 ++++++++++++++++++ .../Dashboard/__tests__/useDashboard.test.ts | 78 ++++++++++++++ src/pages/Dashboard/dashboardFeatures.ts | 38 +++++++ src/pages/Dashboard/index.tsx | 88 +++++++-------- src/pages/Dashboard/useDashboard.ts | 31 ++++++ src/pages/QrCode/index.tsx | 1 - src/pages/README.md | 7 +- src/utils/__tests__/base64Converter.test.ts | 2 +- src/utils/__tests__/jsonToToml.test.ts | 8 -- src/utils/__tests__/jsonToYaml.test.ts | 5 - src/utils/__tests__/qrCodeParser.test.ts | 1 - src/utils/qrCodeParser.ts | 1 - 13 files changed, 338 insertions(+), 71 deletions(-) create mode 100644 src/pages/Dashboard/__tests__/dashboardFeatures.test.ts create mode 100644 src/pages/Dashboard/__tests__/index.test.tsx create mode 100644 src/pages/Dashboard/__tests__/useDashboard.test.ts create mode 100644 src/pages/Dashboard/dashboardFeatures.ts create mode 100644 src/pages/Dashboard/useDashboard.ts diff --git a/src/pages/Dashboard/__tests__/dashboardFeatures.test.ts b/src/pages/Dashboard/__tests__/dashboardFeatures.test.ts new file mode 100644 index 0000000..c52609c --- /dev/null +++ b/src/pages/Dashboard/__tests__/dashboardFeatures.test.ts @@ -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']); + }); +}); diff --git a/src/pages/Dashboard/__tests__/index.test.tsx b/src/pages/Dashboard/__tests__/index.test.tsx new file mode 100644 index 0000000..5810dd4 --- /dev/null +++ b/src/pages/Dashboard/__tests__/index.test.tsx @@ -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(); + + 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(); + + 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(); + + expect(screen.getByText('最近使用')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'JWT 解析' })).toBeInTheDocument(); + }); + + it('无最近使用数据时不应显示最近使用区块', () => { + render(); + + 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(); + + await user.click(screen.getByRole('button', { name: 'JWT 解析' })); + + expect(mockNavigateTo).toHaveBeenCalledWith('jwt'); + }); + + it('无可见工具时应显示空状态', () => { + mockUseDashboard.mockReturnValue({ + visibleFeatures: [], + recentFeatures: [], + showRecent: false, + navigateTo: mockNavigateTo, + }); + + render(); + + expect(screen.getByText('没有可用的工具')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Dashboard/__tests__/useDashboard.test.ts b/src/pages/Dashboard/__tests__/useDashboard.test.ts new file mode 100644 index 0000000..180c2bd --- /dev/null +++ b/src/pages/Dashboard/__tests__/useDashboard.test.ts @@ -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']); + }); +}); diff --git a/src/pages/Dashboard/dashboardFeatures.ts b/src/pages/Dashboard/dashboardFeatures.ts new file mode 100644 index 0000000..ee38d29 --- /dev/null +++ b/src/pages/Dashboard/dashboardFeatures.ts @@ -0,0 +1,38 @@ +import { getFeatureByKey, type FeatureConfig } from '@/config/features'; +import type { PageType } from '@/types/storage'; + +export type DashboardFeature = FeatureConfig & { + icon: NonNullable; +}; + +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; +} diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 7fda2a9..72c532e 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -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(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 (
- {/* 最近使用 */} {showRecent && (

@@ -30,12 +13,12 @@ export default function Index() {

{recentFeatures.map(({ key, feature }) => { - const IconComponent = feature!.icon!; + const IconComponent = feature.icon; return ( ); })} @@ -52,40 +35,45 @@ export default function Index() {
)} - {/* 全部工具 — 紧凑 Grid */}

{'全部工具'}

-
- {visibleFeatures.map(({ key, feature }) => { - const IconComponent = feature!.icon!; - return ( - - ); - })} -
+ > + + + {feature.label} + + + ); + })} +
+ )}
); diff --git a/src/pages/Dashboard/useDashboard.ts b/src/pages/Dashboard/useDashboard.ts new file mode 100644 index 0000000..1fdc0d0 --- /dev/null +++ b/src/pages/Dashboard/useDashboard.ts @@ -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 }; +} diff --git a/src/pages/QrCode/index.tsx b/src/pages/QrCode/index.tsx index adb1dde..39ef556 100644 --- a/src/pages/QrCode/index.tsx +++ b/src/pages/QrCode/index.tsx @@ -9,7 +9,6 @@ import type { QrCodeMode } from './types'; export default function Index() { const qrCode = useQrCode(); - // 模式选项驱动骨架 const modeOptions = [ { value: 'generate' as QrCodeMode, label: '文本转二维码' }, { value: 'parse' as QrCodeMode, label: '二维码转文本' }, diff --git a/src/pages/README.md b/src/pages/README.md index c3a15a2..1125899 100644 --- a/src/pages/README.md +++ b/src/pages/README.md @@ -22,10 +22,9 @@ pages/FeatureName/ 仪表盘首页,以卡片网格展示所有可见工具,支持点击导航。 -| 文件 | 用途 | -| -------------- | -------------------------------------------- | -| `index.tsx` | 页面组件,渲染工具卡片网格 | -| `ToolCard.tsx` | 工具卡片组件,展示图标、标题、描述和实时数据 | +| 文件 | 用途 | +| ----------- | -------------------------- | +| `index.tsx` | 页面组件,渲染工具卡片网格 | ### Timestamp/ diff --git a/src/utils/__tests__/base64Converter.test.ts b/src/utils/__tests__/base64Converter.test.ts index 95ff976..2d8c8a4 100644 --- a/src/utils/__tests__/base64Converter.test.ts +++ b/src/utils/__tests__/base64Converter.test.ts @@ -316,7 +316,7 @@ describe('base64ToBlob', () => { }); it('应该对非法 Base64 抛出 Invalid Base64 string', () => { - expect(() => base64ToBlob('这不是 base64!')).toThrow('Invalid Base64 string'); + expect(() => base64ToBlob('这不是 base64!')).toThrow('无效的 Base64 字符串'); }); it('应该返回原始 Base64(已去除 data URI 前缀)', () => { diff --git a/src/utils/__tests__/jsonToToml.test.ts b/src/utils/__tests__/jsonToToml.test.ts index c4bff22..9b82c85 100644 --- a/src/utils/__tests__/jsonToToml.test.ts +++ b/src/utils/__tests__/jsonToToml.test.ts @@ -76,14 +76,6 @@ describe('jsonToToml', () => { expect(() => jsonToToml('{invalid}')).toThrow(SyntaxError); }); - it('should throw Error for non-object top-level value', () => { - expect(() => jsonToToml('"hello"')).toThrow( - 'TOML requires the top-level value to be an object', - ); - expect(() => jsonToToml('[1,2]')).toThrow('TOML requires the top-level value to be an object'); - expect(() => jsonToToml('null')).toThrow('TOML requires the top-level value to be an object'); - }); - it('should escape special characters in strings', () => { const result = jsonToToml('{"path":"C:\\\\Users\\\\test"}'); expect(result.output).toContain('"C:\\\\Users\\\\test"'); diff --git a/src/utils/__tests__/jsonToYaml.test.ts b/src/utils/__tests__/jsonToYaml.test.ts index 7090424..c06351e 100644 --- a/src/utils/__tests__/jsonToYaml.test.ts +++ b/src/utils/__tests__/jsonToYaml.test.ts @@ -86,11 +86,6 @@ describe('jsonToYaml', () => { expect(() => jsonToYaml('{invalid}')).toThrow(SyntaxError); }); - it('should handle keys with special characters', () => { - const result = jsonToYaml('{"key with spaces":"value"}'); - expect(result.output).toContain('"key with spaces"'); - }); - it('should handle nested arrays in objects', () => { const result = jsonToYaml('{"items":[1,2,3]}'); expect(result.output).toContain('items:'); diff --git a/src/utils/__tests__/qrCodeParser.test.ts b/src/utils/__tests__/qrCodeParser.test.ts index 65573f7..8d9782d 100644 --- a/src/utils/__tests__/qrCodeParser.test.ts +++ b/src/utils/__tests__/qrCodeParser.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import QrScanner from 'qr-scanner'; -// Mock qr-scanner vi.mock('qr-scanner', () => ({ default: { scanImage: vi.fn(), diff --git a/src/utils/qrCodeParser.ts b/src/utils/qrCodeParser.ts index 63ef41c..7cfd5b3 100644 --- a/src/utils/qrCodeParser.ts +++ b/src/utils/qrCodeParser.ts @@ -8,7 +8,6 @@ interface QrCodeParseResult { /** * 从文件中解析二维码 - * 使用 qr-scanner 替代 jsqr 以减小体积并提高性能 */ export async function parseQrCodeFromFile(file: File): Promise { try {