Merge develop into main (#27)

* feat: 添加 contextMenus 权限和消息类型定义

* feat: 实现右键菜单注册与点击分流逻辑

- 新增 utils/contextMenu.ts 封装菜单配置和解析函数
- 在 background.ts 监听 onInstalled 初始化菜单
- 实现 contextMenus.onClicked 点击事件处理
- 分流逻辑:优先发送到侧边栏,否则打开 options 页面
- 添加 contextMenu 单元测试(15个测试用例)
- 更新 vitest.setup.ts 添加 contextMenus mock

* feat: 实现 Content Script 原位轻量提示 UI

- 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏)
- 新增 contextMenuHandler.ts 处理右键菜单消息
- 时间戳转换:在点击位置显示转换结果
- 文本统计:显示字符/单词/行数/字节统计
- 更新 messageHandler.ts 挂载右键菜单消息监听

* feat: 实现 React 页面层右键菜单数据联动

- 新增 useContextMenuData Hook 处理右键菜单数据传递
- 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据
- RouterProvider 支持从 URL 参数解析右键菜单数据
- 添加 contextMenu/pendingData 存储键到 StorageSchema
- 添加 useContextMenuData 单元测试(12个测试用例)

* test: 添加 background 单元测试与边界情况处理

- 新增 entrypoints/__tests__/background.test.ts (12个测试用例)
- 超长文本截断限制 (>10000字符)
- Base64 内联图片拦截并返回错误提示
- 更新 parseContextMenuClick 返回 ParseResult 格式
- 更新测试匹配新的返回格式
- lint/typecheck/test 全部通过 (579个测试)

* fix: 修复右键菜单跳转到错误页面的问题

- 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html'
- WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html

* fix: 修复右键菜单跳转目标为 popup 页面

- 将 fallback 页面从 options.html 改为 popup.html
- popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面
- options 页面是独立设置页,不支持 URL 参数路由

* fix: 使用 openPopup() 替代 tabs.create() 打开弹窗

- background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗
- 先保存数据到 storage,然后打开 popup
- RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面
- 移除之前错误的 tabs.create() 方式

* fix: 修复右键菜单数据未传递到目标页面的问题

- RouterProvider 不再提前清除 pendingData
- 让目标页面的 useContextMenuData 来消费和清除数据
- 这样确保 TextStatistics 等页面能正确接收选中的文本

* fix: 修复 popup 已打开时右键菜单不生效的问题

- 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听
- 当 pendingData 变化时,自动跳转到对应功能页面
- 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题

* feat: 支持右键菜单图片二维码识别

- 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码
- 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL
- 自动下载图片并解析二维码,显示解析结果

* refactor: 暂时移除图片二维码识别功能

- 删除 qrCode-image 右键菜单配置
- 删除 parseQrCodeFromUrl 函数
- 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码
- 更新相关测试用例

* feat: 支持右键菜单时间戳转换

- 在 useTimestampConverter 中添加 useContextMenuData hook
- 智能识别输入是时间戳还是日期时间字符串
- 自动切换到对应模式并执行转换
- 时间戳自动识别秒/毫秒单位

* fix: 修复右键菜单功能的多个逻辑漏洞

1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai
2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据
3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用
4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID

* refactor(qrcode): 提取独立组件并添加单元测试

- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)

* refactor(qrcode): 引入通用组件并重构双栏布局

- 引入 SwitchButtonGroup 用于模式切换
- 引入 TextInputArea 用于文本输入和结果展示
- 使用 MUI Grid 构建响应式双栏布局
- 实现 generate/parse 模式下的左右面板内容切换
- 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor

* refactor(qrcode): 国际化补充、质量保障与错误处理优化

- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过

* refactor(qrcode): 组件化重构,采用 Context + Hook 模式

- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过

* fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动

- 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250
- 与 QRious 生成的二维码大小保持一致
- 解决输入内容变化时预览窗口大小跳动问题

* fix(qrcode): 修复切换 tab 后二维码图片失效问题

- 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑
- 在 useQrCode hook 中统一管理预览 URL 生命周期
- 创建新预览 URL 前释放旧的,避免内存泄漏

* fix(qrcode): 固定图片上传区域高度,避免布局抖动

- 将 DROPZONE 的 minHeight: 200 改为 height: 250
- 与二维码预览图片高度保持一致
- 解决上传图片时 div 高度变化问题

* refactor(qrCode): 优化二维码解析功能

- 将'二维码转 URL'改为'二维码转文本'
- 将解析结果的文本预览框的 placeholder 置为空
- 去除手动解析二维码功能,只保留自动解析

* fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式

* feat(pageHeader): popup 模式下隐藏 PageHeader 组件

* fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁

* perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积

- 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效
- 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks)
- 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB)
- 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB)
- PageErrorBoundary chunk 从 454KB 降至 36KB
This commit is contained in:
LingandRX
2026-05-21 01:34:35 +08:00
committed by GitHub
parent 373fc0496c
commit a5c34e5428
52 changed files with 2610 additions and 622 deletions
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
CONTEXT_MENU_CONFIGS,
createAllContextMenus,
parseContextMenuClick,
MAX_PAYLOAD_LENGTH,
} from '@/utils/contextMenu';
describe('contextMenu', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('CONTEXT_MENU_CONFIGS', () => {
it('应该包含 7 个菜单项配置', () => {
expect(CONTEXT_MENU_CONFIGS).toHaveLength(7);
});
it('应该有一个父级菜单项 Testing Tools', () => {
const parentMenu = CONTEXT_MENU_CONFIGS.find((c) => c.id === 'testing-tools-parent');
expect(parentMenu).toBeDefined();
expect(parentMenu?.title).toBe('Testing Tools');
expect(parentMenu?.parentId).toBeUndefined();
});
it('应该有 4 个 selection 上下文的子菜单', () => {
const selectionMenus = CONTEXT_MENU_CONFIGS.filter(
(c) => c.contexts[0] === 'selection' && c.parentId === 'testing-tools-parent',
);
expect(selectionMenus).toHaveLength(4);
expect(selectionMenus.map((m) => m.id)).toEqual([
'jwt',
'base64Converter',
'textStatistics',
'timestamp',
]);
});
it('应该有 2 个 page 上下文的子菜单', () => {
const pageMenus = CONTEXT_MENU_CONFIGS.filter(
(c) => c.contexts[0] === 'page' && c.parentId === 'testing-tools-parent',
);
expect(pageMenus).toHaveLength(2);
expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']);
});
});
describe('createAllContextMenus', () => {
it('应该为每个配置调用 chrome.contextMenus.create', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7);
});
it('应该使用正确的参数创建菜单项', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
id: 'testing-tools-parent',
title: 'Testing Tools',
contexts: ['all'],
parentId: undefined,
});
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
id: 'jwt',
title: '🔑 解析 JWT',
contexts: ['selection'],
parentId: 'testing-tools-parent',
});
});
});
describe('parseContextMenuClick', () => {
const createMockOnClickData = (
overrides: Partial<chrome.contextMenus.OnClickData> = {},
): chrome.contextMenus.OnClickData => ({
menuItemId: 'test',
editable: false,
pageUrl: 'https://example.com',
...overrides,
});
it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => {
const info = createMockOnClickData({
pageUrl: 'https://example.com/page',
});
const result = parseContextMenuClick('qrCode-page', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'qrCode', payload: 'https://example.com/page' },
});
});
it('当点击有 selectionText 的菜单时应返回对应功能和选中文本', () => {
const info = createMockOnClickData({
selectionText: 'selected text',
});
const result = parseContextMenuClick('jwt', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'jwt', payload: 'selected text' },
});
});
it('当点击 timestamp 菜单时应正确映射功能键', () => {
const info = createMockOnClickData({
selectionText: '1234567890',
});
const result = parseContextMenuClick('timestamp', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'timestamp', payload: '1234567890' },
});
});
it('当点击 storageCleaner 菜单时应返回 pageUrl', () => {
const info = createMockOnClickData({
pageUrl: 'https://example.com',
});
const result = parseContextMenuClick('storageCleaner', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'storageCleaner', payload: 'https://example.com' },
});
});
it('当没有 selectionText 和 pageUrl 时应返回错误', () => {
const info = createMockOnClickData({
pageUrl: undefined,
});
const result = parseContextMenuClick('someMenu', info);
expect(result).toEqual({
success: false,
error: '无法获取有效数据',
});
});
it('selectionText 优先于 pageUrl', () => {
const info = createMockOnClickData({
selectionText: 'selected text',
pageUrl: 'https://example.com',
});
const result = parseContextMenuClick('jwt', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'jwt', payload: 'selected text' },
});
});
it('当文本超过最大长度限制时应截断', () => {
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
const info = createMockOnClickData({
selectionText: longText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result.success).toBe(true);
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
});
it('当文本未超过最大长度限制时应保持原样', () => {
const shortText = 'short text';
const info = createMockOnClickData({
selectionText: shortText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'textStatistics', payload: 'short text' },
});
});
});
});
+225
View File
@@ -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();
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import type { PageType } from '@/types/storage';
export interface ContextMenuItemConfig {
id: string;
title: string;
contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]];
parentId?: string;
}
export interface ContextMenuClickedInfo {
featureKey: PageType;
payload: string;
}
export interface ParseResult {
success: boolean;
data?: ContextMenuClickedInfo;
error?: string;
}
const PARENT_MENU_ID = 'testing-tools-parent';
export const MAX_PAYLOAD_LENGTH = 10000;
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
'qrCode-page': 'qrCode',
};
/**
* 将菜单项 ID 转换为 PageType
* 如果存在显式映射则使用映射,否则直接使用 menuItemId
*/
function getMenuPageType(menuItemId: string): PageType {
return MENU_ID_TO_PAGE_TYPE[menuItemId] ?? (menuItemId as PageType);
}
export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [
{
id: PARENT_MENU_ID,
title: 'Testing Tools',
contexts: [chrome.contextMenus.ContextType.ALL],
},
{
id: 'jwt',
title: '🔑 解析 JWT',
contexts: [chrome.contextMenus.ContextType.SELECTION],
parentId: PARENT_MENU_ID,
},
{
id: 'base64Converter',
title: '🔄 Base64 解码',
contexts: [chrome.contextMenus.ContextType.SELECTION],
parentId: PARENT_MENU_ID,
},
{
id: 'textStatistics',
title: '📊 统计选中文本',
contexts: [chrome.contextMenus.ContextType.SELECTION],
parentId: PARENT_MENU_ID,
},
{
id: 'timestamp',
title: '⏰ 转换时间戳',
contexts: [chrome.contextMenus.ContextType.SELECTION],
parentId: PARENT_MENU_ID,
},
{
id: 'storageCleaner',
title: '🧹 清理当前网站存储',
contexts: [chrome.contextMenus.ContextType.PAGE],
parentId: PARENT_MENU_ID,
},
{
id: 'qrCode-page',
title: '🔗 网页链接转二维码',
contexts: [chrome.contextMenus.ContextType.PAGE],
parentId: PARENT_MENU_ID,
},
];
export function createAllContextMenus(): void {
for (const config of CONTEXT_MENU_CONFIGS) {
chrome.contextMenus.create({
id: config.id,
title: config.title,
contexts: config.contexts,
parentId: config.parentId,
});
}
}
export function parseContextMenuClick(
menuItemId: string,
info: chrome.contextMenus.OnClickData,
): ParseResult {
const featureKey = getMenuPageType(menuItemId);
if (info.selectionText) {
const text = info.selectionText;
if (text.length > MAX_PAYLOAD_LENGTH) {
return {
success: true,
data: { featureKey, payload: text.substring(0, MAX_PAYLOAD_LENGTH) },
};
}
return {
success: true,
data: { featureKey, payload: text },
};
}
if (info.pageUrl) {
return {
success: true,
data: { featureKey, payload: info.pageUrl },
};
}
return { success: false, error: '无法获取有效数据' };
}
+7
View File
@@ -3,6 +3,7 @@ import { defineExtensionMessaging } from '@webext-core/messaging';
export enum MessageAction {
RELOAD_TAB = 'reloadTab',
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
}
export interface MessageResponse {
@@ -11,9 +12,15 @@ export interface MessageResponse {
error?: string;
}
export interface ContextMenuClickedPayload {
featureKey: string;
payload: string;
}
export interface ProtocolMap {
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import type { ContextMenuPendingData, PageType } from '@/types/storage';
const STORAGE_KEY = 'contextMenu/pendingData' as const;
/** 右键菜单数据过期时间(毫秒) */
export const CONTEXT_MENU_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 > CONTEXT_MENU_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);
}
+24
View File
@@ -0,0 +1,24 @@
import { useState, useEffect } from 'react';
/**
* useDebounce Hook - 防抖值
*
* @param value - 需要防抖的值
* @param delay - 延迟时间(毫秒)
* @returns 防抖后的值
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}