From 3b98c9438e024f7159dab0f420f5b07a2602f723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Wed, 20 May 2026 20:51:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20React=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=B1=82=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useContextMenuData Hook 处理右键菜单数据传递 - 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据 - RouterProvider 支持从 URL 参数解析右键菜单数据 - 添加 contextMenu/pendingData 存储键到 StorageSchema - 添加 useContextMenuData 单元测试(12个测试用例) --- pages/Base64Converter/TextMode.tsx | 20 ++ pages/Jwt/index.tsx | 10 +- pages/QrCode/UrlToQrCodeSection.tsx | 72 ++++--- pages/TextStatistics/index.tsx | 9 +- providers/RouterProvider.tsx | 19 ++ types/storage.d.ts | 14 ++ utils/__tests__/useContextMenuData.test.ts | 225 +++++++++++++++++++++ utils/useContextMenuData.ts | 83 ++++++++ 8 files changed, 422 insertions(+), 30 deletions(-) create mode 100644 utils/__tests__/useContextMenuData.test.ts create mode 100644 utils/useContextMenuData.ts diff --git a/pages/Base64Converter/TextMode.tsx b/pages/Base64Converter/TextMode.tsx index 6d580fa..4400141 100644 --- a/pages/Base64Converter/TextMode.tsx +++ b/pages/Base64Converter/TextMode.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import CopyButton from '@/components/CopyButton'; import { textToBase64, base64ToText } from '@/utils/base64Converter'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; +import { useContextMenuData } from '@/utils/useContextMenuData'; const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i; @@ -26,6 +27,25 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { const [error, setError] = useState(null); const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); + const handleContextMenuData = useCallback( + (payload: string) => { + setInput(payload); + setDirection('decode'); + setError(null); + try { + const decoded = base64ToText(payload); + setOutput(decoded); + } catch (e) { + const message = e instanceof Error ? e.message : ''; + const i18nKey = ERROR_MESSAGE_TO_I18N[message]; + setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); + } + }, + [t], + ); + + useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData }); + const actionLabel = direction === 'encode' ? t('encode') : t('decode'); const placeholder = direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder'); diff --git a/pages/Jwt/index.tsx b/pages/Jwt/index.tsx index 03de6ec..4238a8b 100644 --- a/pages/Jwt/index.tsx +++ b/pages/Jwt/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Box, Container, Paper, Stack, Typography } from '@mui/material'; import { useSnackbar } from '@/components/GlobalSnackbar'; import VpnKeyIcon from '@mui/icons-material/VpnKey'; @@ -7,6 +7,7 @@ import { stringifyJson, parseJwt } from '@/utils/jwt'; import CopyButton from '@/components/CopyButton'; import TextInputArea from '@/components/TextInputArea'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import { useContextMenuData } from '@/utils/useContextMenuData'; interface SectionProps { title: string; @@ -61,6 +62,13 @@ export default function Index() { const { t } = useLazyTranslation('jwt'); const [jwtInput, setJwtInput] = useState(''); + const handleContextMenuData = useCallback((payload: string) => { + const cleaned = payload.replace(/^Bearer\s*/i, '').trim(); + setJwtInput(cleaned); + }, []); + + useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData }); + const result = useMemo(() => { if (!jwtInput.trim()) { return null; diff --git a/pages/QrCode/UrlToQrCodeSection.tsx b/pages/QrCode/UrlToQrCodeSection.tsx index dc7edfc..15d87ad 100644 --- a/pages/QrCode/UrlToQrCodeSection.tsx +++ b/pages/QrCode/UrlToQrCodeSection.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Accordion, AccordionDetails, @@ -18,6 +18,7 @@ import QRious from 'qrious'; import { qrCodePageStyles } from '@/config/pageTheme'; import { useSnackbar } from '@/components/GlobalSnackbar'; import { useTranslation } from 'react-i18next'; +import { useContextMenuData } from '@/utils/useContextMenuData'; interface UrlToQrCodeSectionProps { expanded: boolean; @@ -38,6 +39,47 @@ const UrlToQrCodeSection = ({ const [qrCodeDataUrl, setQrCodeDataUrl] = useState(''); const [generating, setGenerating] = useState(false); + const generateQrCodeFromUrl = useCallback( + async (url: string) => { + try { + setGenerating(true); + setUrlError(''); + + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + + const qr = new QRious({ + value: url, + size: 250, + level: 'H', + foreground: '#000000', + background: '#FFFFFF', + }); + + setQrCodeDataUrl(qr.toDataURL()); + showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 }); + } catch (error) { + console.error('生成二维码失败:', error); + showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); + } finally { + setGenerating(false); + } + }, + [t, showMessage], + ); + + const handleContextMenuData = useCallback( + (payload: string) => { + setUrlInput(payload); + onExpandedChange(true); + generateQrCodeFromUrl(payload); + }, + [onExpandedChange, generateQrCodeFromUrl], + ); + + useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); + const handleUrlInputChange = (e: React.ChangeEvent) => { setUrlInput(e.target.value); setUrlError(''); @@ -48,33 +90,7 @@ const UrlToQrCodeSection = ({ setUrlError(t('qrCode:enterUrlError')); return; } - - try { - setGenerating(true); - setUrlError(''); - - let url = urlInput; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; - } - - // 使用 QRious 替代 qrcode 库,体积更小 - const qr = new QRious({ - value: url, - size: 250, - level: 'H', - foreground: '#000000', - background: '#FFFFFF', - }); - - setQrCodeDataUrl(qr.toDataURL()); - showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 }); - } catch (error) { - console.error('生成二维码失败:', error); - showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); - } finally { - setGenerating(false); - } + await generateQrCodeFromUrl(urlInput); }; const downloadQrCode = () => { diff --git a/pages/TextStatistics/index.tsx b/pages/TextStatistics/index.tsx index 20c58c7..2caa79e 100644 --- a/pages/TextStatistics/index.tsx +++ b/pages/TextStatistics/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { alpha, Box, Container, Grid, Paper, Typography } from '@mui/material'; import PageHeader from '@/components/PageHeader'; import TextInputArea from '@/components/TextInputArea'; @@ -6,6 +6,7 @@ import DescriptionIcon from '@mui/icons-material/Description'; import { formatByteSize, getTextStats } from '@/utils/textStatistics'; import { textStatisticsPageStyles } from '@/config/pageTheme'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import { useContextMenuData } from '@/utils/useContextMenuData'; /** * 文本统计页面组件 @@ -16,6 +17,12 @@ export default function Index() { const { t } = useLazyTranslation('textStatistics'); const [text, setText] = useState(''); + const handleContextMenuData = useCallback((payload: string) => { + setText(payload); + }, []); + + useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData }); + // 实时计算统计信息,使用 useMemo 优化性能 // 对于 10,000 字符以上的文本,Intl.Segmenter 也能保持良好的性能 const stats = useMemo(() => getTextStats(text), [text]); diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index f87c650..1f72adf 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -14,6 +14,7 @@ import { getDefaultPageOrder, getDefaultVisibleFeatureKeys, } from '@/config/features'; +import { saveContextMenuData } from '@/utils/useContextMenuData'; /** * 校验是否为合法的页面类型 @@ -187,6 +188,24 @@ export function RouterProvider({ .then(() => { if (!cancelled) { setIsLoaded(true); + + // 检查 URL 参数中的右键菜单数据 + if (typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const feature = params.get('feature') as PageType | null; + const payload = params.get('payload'); + + if (feature && payload && isValidPage(feature)) { + saveContextMenuData({ featureKey: feature, payload }).catch(console.error); + navigateTo(feature); + + // 清理 URL 参数 + const url = new URL(window.location.href); + url.searchParams.delete('feature'); + url.searchParams.delete('payload'); + window.history.replaceState({}, '', url.toString()); + } + } } }) .catch(console.error); diff --git a/types/storage.d.ts b/types/storage.d.ts index 4b55efb..7d95029 100644 --- a/types/storage.d.ts +++ b/types/storage.d.ts @@ -126,6 +126,20 @@ export interface StorageSchema { 'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode; /** 语言偏好设置 */ 'app/language': string; + /** 右键菜单待处理数据 */ + 'contextMenu/pendingData': ContextMenuPendingData; +} + +/** + * 右键菜单待处理数据 + */ +export interface ContextMenuPendingData { + /** 功能标识 */ + featureKey: PageType; + /** 捕获的文本或图片 URL */ + payload: string; + /** 数据创建时间戳 */ + timestamp: number; } /** diff --git a/utils/__tests__/useContextMenuData.test.ts b/utils/__tests__/useContextMenuData.test.ts new file mode 100644 index 0000000..e0c9b97 --- /dev/null +++ b/utils/__tests__/useContextMenuData.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + useContextMenuData, + saveContextMenuData, + clearContextMenuData, +} from '@/utils/useContextMenuData'; + +describe('useContextMenuData', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('saveContextMenuData', () => { + it('应该保存数据到 storage 并添加时间戳', async () => { + const data = { featureKey: 'jwt' as const, payload: 'test-token' }; + vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); + + await saveContextMenuData(data); + + expect(chrome.storage.local.set).toHaveBeenCalledWith({ + 'contextMenu/pendingData': { + featureKey: 'jwt', + payload: 'test-token', + timestamp: 1704110400000, + }, + }); + }); + + it('应该正确处理不同的 featureKey', async () => { + const data = { featureKey: 'timestamp' as const, payload: '1234567890' }; + + await saveContextMenuData(data); + + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'contextMenu/pendingData': expect.objectContaining({ + featureKey: 'timestamp', + payload: '1234567890', + }), + }), + ); + }); + }); + + describe('clearContextMenuData', () => { + it('应该从 storage 中删除数据', async () => { + await clearContextMenuData(); + + expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']); + }); + }); + + describe('useContextMenuData Hook', () => { + it('当 storage 中有匹配数据时应调用 onData 回调', async () => { + const mockData = { + featureKey: 'jwt', + payload: 'test-token', + timestamp: Date.now(), + }; + (chrome.storage.local.get as any).mockResolvedValue({ + 'contextMenu/pendingData': mockData, + }); + + const onData = vi.fn(); + renderHook(() => useContextMenuData({ featureKey: 'jwt', onData })); + + await vi.waitFor(() => { + expect(onData).toHaveBeenCalledWith('test-token'); + }); + }); + + it('当 storage 中没有数据时不应调用 onData 回调', async () => { + (chrome.storage.local.get as any).mockResolvedValue({}); + + const onData = vi.fn(); + renderHook(() => useContextMenuData({ featureKey: 'jwt', onData })); + + await vi.waitFor(() => { + expect(onData).not.toHaveBeenCalled(); + }); + }); + + it('当 featureKey 不匹配时不应调用 onData 回调', async () => { + const mockData = { + featureKey: 'timestamp', + payload: '1234567890', + timestamp: Date.now(), + }; + (chrome.storage.local.get as any).mockResolvedValue({ + 'contextMenu/pendingData': mockData, + }); + + const onData = vi.fn(); + renderHook(() => useContextMenuData({ featureKey: 'jwt', onData })); + + await vi.waitFor(() => { + expect(onData).not.toHaveBeenCalled(); + }); + }); + + it('当数据过期时不应调用 onData 回调并删除数据', async () => { + const now = Date.now(); + vi.setSystemTime(now); + + const mockData = { + featureKey: 'jwt', + payload: 'test-token', + timestamp: now - 6000, + }; + (chrome.storage.local.get as any).mockResolvedValue({ + 'contextMenu/pendingData': mockData, + }); + + const onData = vi.fn(); + renderHook(() => useContextMenuData({ featureKey: 'jwt', onData })); + + await vi.waitFor(() => { + expect(onData).not.toHaveBeenCalled(); + expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']); + }); + }); + + it('消费数据后应删除 storage 中的数据', async () => { + const mockData = { + featureKey: 'jwt', + payload: 'test-token', + timestamp: Date.now(), + }; + (chrome.storage.local.get as any).mockResolvedValue({ + 'contextMenu/pendingData': mockData, + }); + + const onData = vi.fn(); + renderHook(() => useContextMenuData({ featureKey: 'jwt', onData })); + + await vi.waitFor(() => { + expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']); + }); + }); + + it('当 storage 变化且 featureKey 匹配时应调用 onData 回调', async () => { + (chrome.storage.local.get as any).mockResolvedValue({}); + + const onData = vi.fn(); + renderHook(() => useContextMenuData({ featureKey: 'jwt', onData })); + + const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0]; + + const mockData = { + featureKey: 'jwt', + payload: 'new-token', + timestamp: Date.now(), + }; + (chrome.storage.local.get as any).mockResolvedValue({ + 'contextMenu/pendingData': mockData, + }); + + await act(async () => { + storageChangeHandler({ + 'contextMenu/pendingData': { newValue: mockData }, + }); + }); + + await vi.waitFor(() => { + expect(onData).toHaveBeenCalledWith('new-token'); + }); + }); + + it('当 storage 变化但 featureKey 不匹配时不应调用 onData 回调', async () => { + (chrome.storage.local.get as any).mockResolvedValue({}); + + const onData = vi.fn(); + renderHook(() => useContextMenuData({ featureKey: 'jwt', onData })); + + const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0]; + + const mockData = { + featureKey: 'timestamp', + payload: '1234567890', + timestamp: Date.now(), + }; + + await act(async () => { + storageChangeHandler({ + 'contextMenu/pendingData': { newValue: mockData }, + }); + }); + + expect(onData).not.toHaveBeenCalled(); + }); + + it('当 storage 变化但数据被删除时不应调用 onData 回调', async () => { + (chrome.storage.local.get as any).mockResolvedValue({}); + + const onData = vi.fn(); + renderHook(() => useContextMenuData({ featureKey: 'jwt', onData })); + + const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0]; + + await act(async () => { + storageChangeHandler({ + 'contextMenu/pendingData': { newValue: null }, + }); + }); + + expect(onData).not.toHaveBeenCalled(); + }); + + it('组件卸载时应移除 storage 变化监听器', () => { + const { unmount } = renderHook(() => + useContextMenuData({ featureKey: 'jwt', onData: vi.fn() }), + ); + + unmount(); + + expect(chrome.storage.onChanged.removeListener).toHaveBeenCalled(); + }); + }); +}); diff --git a/utils/useContextMenuData.ts b/utils/useContextMenuData.ts new file mode 100644 index 0000000..cdc78cd --- /dev/null +++ b/utils/useContextMenuData.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect } from 'react'; +import { storageUtil } from '@/utils/chromeStorage'; +import type { ContextMenuPendingData, PageType } from '@/types/storage'; + +const STORAGE_KEY = 'contextMenu/pendingData' as const; +const DATA_EXPIRY_MS = 5000; + +export interface UseContextMenuDataOptions { + /** 当前页面的功能标识 */ + featureKey: PageType; + /** 收到数据时的回调函数 */ + onData: (payload: string) => void; +} + +/** + * 自定义 Hook:处理右键菜单传递的数据 + * + * 使用方式: + * 1. 在页面组件中调用此 Hook + * 2. 传入当前页面的 featureKey 和数据处理回调 + * 3. Hook 会自动从 storage 中读取并消费匹配的数据 + */ +export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void { + const checkAndConsumeData = useCallback(async () => { + try { + const data = await storageUtil.get(STORAGE_KEY, undefined); + + if (!data) return; + + if (data.featureKey !== featureKey) return; + + if (Date.now() - data.timestamp > DATA_EXPIRY_MS) { + await storageUtil.remove(STORAGE_KEY); + return; + } + + await storageUtil.remove(STORAGE_KEY); + + onData(data.payload); + } catch (error) { + console.error('[useContextMenuData] 处理右键菜单数据失败:', error); + } + }, [featureKey, onData]); + + useEffect(() => { + checkAndConsumeData(); + }, [checkAndConsumeData]); + + useEffect(() => { + const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => { + if (changes[STORAGE_KEY]) { + const newData = changes[STORAGE_KEY].newValue as ContextMenuPendingData | null; + if (newData && newData.featureKey === featureKey) { + checkAndConsumeData(); + } + } + }; + + chrome.storage.onChanged.addListener(handleStorageChange); + return () => chrome.storage.onChanged.removeListener(handleStorageChange); + }, [featureKey, checkAndConsumeData]); +} + +/** + * 保存右键菜单数据到 storage + * 由 RouterProvider 或入口组件调用 + */ +export async function saveContextMenuData( + data: Omit, +): Promise { + const pendingData: ContextMenuPendingData = { + ...data, + timestamp: Date.now(), + }; + await storageUtil.set(STORAGE_KEY, pendingData); +} + +/** + * 清除右键菜单待处理数据 + */ +export async function clearContextMenuData(): Promise { + await storageUtil.remove(STORAGE_KEY); +}