feat: 实现 React 页面层右键菜单数据联动
- 新增 useContextMenuData Hook 处理右键菜单数据传递 - 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据 - RouterProvider 支持从 URL 参数解析右键菜单数据 - 添加 contextMenu/pendingData 存储键到 StorageSchema - 添加 useContextMenuData 单元测试(12个测试用例)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ContextMenuPendingData, 'timestamp'>,
|
||||
): Promise<void> {
|
||||
const pendingData: ContextMenuPendingData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await storageUtil.set(STORAGE_KEY, pendingData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除右键菜单待处理数据
|
||||
*/
|
||||
export async function clearContextMenuData(): Promise<void> {
|
||||
await storageUtil.remove(STORAGE_KEY);
|
||||
}
|
||||
Reference in New Issue
Block a user