From 3d375f78f3462259869e2a232f563b0567d8abda 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:35:55 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20contextMenus?= =?UTF-8?q?=20=E6=9D=83=E9=99=90=E5=92=8C=E6=B6=88=E6=81=AF=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/messages.ts | 7 +++++++ wxt.config.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/utils/messages.ts b/utils/messages.ts index 0589406..075b992 100644 --- a/utils/messages.ts +++ b/utils/messages.ts @@ -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(); diff --git a/wxt.config.ts b/wxt.config.ts index 3a04e50..f0218ba 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'tabs', 'cookies', 'sidePanel', + 'contextMenus', ], host_permissions: [''], action: { From 3fb99ea49ff6571991ac2b2cd2d603d00d29eae7 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:41:24 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E6=B3=A8=E5=86=8C=E4=B8=8E=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=88=86=E6=B5=81=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 utils/contextMenu.ts 封装菜单配置和解析函数 - 在 background.ts 监听 onInstalled 初始化菜单 - 实现 contextMenus.onClicked 点击事件处理 - 分流逻辑:优先发送到侧边栏,否则打开 options 页面 - 添加 contextMenu 单元测试(15个测试用例) - 更新 vitest.setup.ts 添加 contextMenus mock --- entrypoints/background.ts | 30 ++++- utils/__tests__/contextMenu.test.ts | 191 ++++++++++++++++++++++++++++ utils/contextMenu.ts | 113 ++++++++++++++++ vitest.setup.ts | 22 ++++ 4 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 utils/__tests__/contextMenu.test.ts create mode 100644 utils/contextMenu.ts diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 549ca01..97d08d3 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,8 +1,36 @@ import '../.wxt/types/imports.d.ts'; import { browser } from 'wxt/browser'; -import { MessageAction, onMessage } from '@/utils/messages'; +import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; +import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu'; export default defineBackground(() => { + browser.runtime.onInstalled.addListener(() => { + createAllContextMenus(); + }); + + browser.contextMenus.onClicked.addListener(async (info, _tab) => { + const result = parseContextMenuClick(info.menuItemId as string, info); + if (!result) return; + + const { featureKey, payload } = result; + + try { + const sidePanelState = await browser.storage.local.get('sidePanelOpen'); + const isSidePanelOpen = sidePanelState.sidePanelOpen === true; + + if (isSidePanelOpen) { + await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload }); + return; + } + } catch { + // sidepanel 未打开或无法通信,继续执行其他方案 + } + + const optionsUrl = chrome.runtime.getURL('/entrypoints/options/index.html'); + const params = new URLSearchParams({ feature: featureKey, payload }); + await browser.tabs.create({ url: `${optionsUrl}?${params.toString()}` }); + }); + // 监听扩展图标点击事件,打开侧边栏 browser.action.onClicked.addListener(async (tab) => { if (tab.id) { diff --git a/utils/__tests__/contextMenu.test.ts b/utils/__tests__/contextMenu.test.ts new file mode 100644 index 0000000..887b490 --- /dev/null +++ b/utils/__tests__/contextMenu.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + CONTEXT_MENU_CONFIGS, + createAllContextMenus, + parseContextMenuClick, +} from '@/utils/contextMenu'; + +describe('contextMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('CONTEXT_MENU_CONFIGS', () => { + it('应该包含 8 个菜单项配置', () => { + expect(CONTEXT_MENU_CONFIGS).toHaveLength(8); + }); + + 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('应该有 1 个 image 上下文的子菜单', () => { + const imageMenus = CONTEXT_MENU_CONFIGS.filter( + (c) => c.contexts[0] === 'image' && c.parentId === 'testing-tools-parent', + ); + expect(imageMenus).toHaveLength(1); + expect(imageMenus[0].id).toBe('qrCode-image'); + }); + + 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(8); + }); + + 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 => ({ + menuItemId: 'test', + editable: false, + pageUrl: 'https://example.com', + ...overrides, + }); + + it('当点击 qrCode-image 菜单时应返回 qrCode 功能和 srcUrl', () => { + const info = createMockOnClickData({ + srcUrl: 'https://example.com/image.png', + }); + + const result = parseContextMenuClick('qrCode-image', info); + + expect(result).toEqual({ + featureKey: 'qrCode', + payload: 'https://example.com/image.png', + }); + }); + + it('当点击 qrCode-image 菜单但没有 srcUrl 时应返回空字符串', () => { + const info = createMockOnClickData({}); + + const result = parseContextMenuClick('qrCode-image', info); + + expect(result).toEqual({ + featureKey: 'qrCode', + payload: '', + }); + }); + + it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => { + const info = createMockOnClickData({ + pageUrl: 'https://example.com/page', + }); + + const result = parseContextMenuClick('qrCode-page', info); + + expect(result).toEqual({ + featureKey: 'qrCode', + payload: 'https://example.com/page', + }); + }); + + it('当点击有 selectionText 的菜单时应返回对应功能和选中文本', () => { + const info = createMockOnClickData({ + selectionText: 'selected text', + }); + + const result = parseContextMenuClick('jwt', info); + + expect(result).toEqual({ + featureKey: 'jwt', + payload: 'selected text', + }); + }); + + it('当点击 timestamp 菜单时应正确映射功能键', () => { + const info = createMockOnClickData({ + selectionText: '1234567890', + }); + + const result = parseContextMenuClick('timestamp', info); + + expect(result).toEqual({ + featureKey: 'timestamp', + payload: '1234567890', + }); + }); + + it('当点击 storageCleaner 菜单时应返回 pageUrl', () => { + const info = createMockOnClickData({ + pageUrl: 'https://example.com', + }); + + const result = parseContextMenuClick('storageCleaner', info); + + expect(result).toEqual({ + featureKey: 'storageCleaner', + payload: 'https://example.com', + }); + }); + + it('当没有 selectionText 和 pageUrl 时应返回 null', () => { + const info = createMockOnClickData({ + pageUrl: undefined, + }); + + const result = parseContextMenuClick('someMenu', info); + + expect(result).toBeNull(); + }); + + it('selectionText 优先于 pageUrl', () => { + const info = createMockOnClickData({ + selectionText: 'selected text', + pageUrl: 'https://example.com', + }); + + const result = parseContextMenuClick('jwt', info); + + expect(result).toEqual({ + featureKey: 'jwt', + payload: 'selected text', + }); + }); + }); +}); diff --git a/utils/contextMenu.ts b/utils/contextMenu.ts new file mode 100644 index 0000000..1de7b8a --- /dev/null +++ b/utils/contextMenu.ts @@ -0,0 +1,113 @@ +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; +} + +const PARENT_MENU_ID = 'testing-tools-parent'; + +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: 'qrCode-image', + title: '🖼️ 识别图中的二维码', + contexts: [chrome.contextMenus.ContextType.IMAGE], + 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, +): ContextMenuClickedInfo | null { + const featureKey = menuItemId as PageType; + + if (menuItemId === 'qrCode-image') { + return { + featureKey: 'qrCode', + payload: info.srcUrl || '', + }; + } + + if (menuItemId === 'qrCode-page') { + return { + featureKey: 'qrCode', + payload: info.pageUrl || '', + }; + } + + if (info.selectionText) { + return { + featureKey, + payload: info.selectionText, + }; + } + + if (info.pageUrl) { + return { + featureKey, + payload: info.pageUrl, + }; + } + + return null; +} diff --git a/vitest.setup.ts b/vitest.setup.ts index c6fb4ec..6544534 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -70,6 +70,28 @@ Object.defineProperty(global, 'chrome', { getAll: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), }, + contextMenus: { + create: vi.fn(), + onClicked: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + ContextType: { + ALL: 'all', + PAGE: 'page', + FRAME: 'frame', + SELECTION: 'selection', + LINK: 'link', + EDITABLE: 'editable', + IMAGE: 'image', + VIDEO: 'video', + AUDIO: 'audio', + LAUNCHER: 'launcher', + BROWSER_ACTION: 'browser_action', + PAGE_ACTION: 'page_action', + ACTION: 'action', + }, + }, }, writable: true, }); From 471e17eedf6817cdadce03135fe428fa90f839a5 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:44:00 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Content=20Sc?= =?UTF-8?q?ript=20=E5=8E=9F=E4=BD=8D=E8=BD=BB=E9=87=8F=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏) - 新增 contextMenuHandler.ts 处理右键菜单消息 - 时间戳转换:在点击位置显示转换结果 - 文本统计:显示字符/单词/行数/字节统计 - 更新 messageHandler.ts 挂载右键菜单消息监听 --- entrypoints/content/contextMenuHandler.ts | 62 ++++++ entrypoints/content/messageHandler.ts | 6 +- entrypoints/content/uiPopover.ts | 247 ++++++++++++++++++++++ 3 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 entrypoints/content/contextMenuHandler.ts create mode 100644 entrypoints/content/uiPopover.ts diff --git a/entrypoints/content/contextMenuHandler.ts b/entrypoints/content/contextMenuHandler.ts new file mode 100644 index 0000000..f412274 --- /dev/null +++ b/entrypoints/content/contextMenuHandler.ts @@ -0,0 +1,62 @@ +import { MessageAction, onMessage } from '@/utils/messages'; +import { getTextStats } from '@/utils/textStatistics'; +import { showTimestampResult, showTextStatsResult, hidePopover } from './uiPopover'; +import type { ContextMenuClickedPayload } from '@/utils/messages'; + +function convertTimestamp(input: string): string { + const num = Number(input.trim()); + if (isNaN(num)) { + return '无效时间戳'; + } + + const d = num > 1e12 ? new Date(num) : new Date(num * 1000); + + if (isNaN(d.getTime())) { + return '无效时间戳'; + } + + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const seconds = String(d.getSeconds()).padStart(2, '0'); + + return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`; +} + +let lastClickX = 0; +let lastClickY = 0; + +document.addEventListener( + 'contextmenu', + (e) => { + lastClickX = e.clientX; + lastClickY = e.clientY; + }, + true, +); + +export function initContextMenuHandler(): void { + onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => { + const { featureKey, payload } = message.data as ContextMenuClickedPayload; + + switch (featureKey) { + case 'timestamp': { + const result = convertTimestamp(payload); + showTimestampResult(lastClickX, lastClickY, payload, result); + break; + } + + case 'textStatistics': { + const stats = getTextStats(payload); + showTextStatsResult(lastClickX, lastClickY, payload, stats); + break; + } + + default: + hidePopover(); + break; + } + }); +} diff --git a/entrypoints/content/messageHandler.ts b/entrypoints/content/messageHandler.ts index 7993b0c..26b3743 100644 --- a/entrypoints/content/messageHandler.ts +++ b/entrypoints/content/messageHandler.ts @@ -1 +1,5 @@ -export function initMessageHandler() {} +import { initContextMenuHandler } from './contextMenuHandler'; + +export function initMessageHandler(): void { + initContextMenuHandler(); +} diff --git a/entrypoints/content/uiPopover.ts b/entrypoints/content/uiPopover.ts new file mode 100644 index 0000000..008f82c --- /dev/null +++ b/entrypoints/content/uiPopover.ts @@ -0,0 +1,247 @@ +const POPOVER_ID = 'testing-tools-popover'; +const POPOVER_STYLE_ID = 'testing-tools-popover-style'; + +function injectStyles(): void { + if (document.getElementById(POPOVER_STYLE_ID)) return; + + const style = document.createElement('style'); + style.id = POPOVER_STYLE_ID; + style.textContent = ` + #${POPOVER_ID} { + position: fixed; + z-index: 2147483647; + max-width: 400px; + min-width: 200px; + padding: 12px 16px; + background: #1a1a2e; + color: #e0e0e0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + line-height: 1.5; + opacity: 0; + transform: translateY(-8px); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; + } + + #${POPOVER_ID}.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + #${POPOVER_ID} .popover-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + #${POPOVER_ID} .popover-title { + font-weight: 600; + font-size: 12px; + color: #a0a0b0; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + #${POPOVER_ID} .popover-close { + background: none; + border: none; + color: #808090; + cursor: pointer; + padding: 2px; + font-size: 16px; + line-height: 1; + } + + #${POPOVER_ID} .popover-close:hover { + color: #e0e0e0; + } + + #${POPOVER_ID} .popover-content { + word-break: break-all; + white-space: pre-wrap; + } + + #${POPOVER_ID} .popover-label { + color: #808090; + font-size: 11px; + margin-bottom: 4px; + } + + #${POPOVER_ID} .popover-value { + color: #ffffff; + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + font-size: 14px; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + margin-bottom: 8px; + } + + #${POPOVER_ID} .popover-value:last-child { + margin-bottom: 0; + } + + #${POPOVER_ID} .stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + #${POPOVER_ID} .stat-item { + padding: 6px 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + } + + #${POPOVER_ID} .stat-label { + font-size: 11px; + color: #808090; + } + + #${POPOVER_ID} .stat-value { + font-size: 16px; + font-weight: 600; + color: #ffffff; + } + `; + document.head.appendChild(style); +} + +function getOrCreatePopover(): HTMLElement { + let popover = document.getElementById(POPOVER_ID); + if (!popover) { + injectStyles(); + popover = document.createElement('div'); + popover.id = POPOVER_ID; + document.body.appendChild(popover); + } + return popover; +} + +function positionPopover(popover: HTMLElement, x: number, y: number): void { + const rect = popover.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = x; + let top = y; + + if (left + rect.width > viewportWidth - 16) { + left = viewportWidth - rect.width - 16; + } + if (left < 16) { + left = 16; + } + + if (top + rect.height > viewportHeight - 16) { + top = y - rect.height - 8; + } + if (top < 16) { + top = 16; + } + + popover.style.left = `${left}px`; + popover.style.top = `${top}px`; +} + +let hideTimeout: ReturnType | null = null; + +export function showPopover( + x: number, + y: number, + content: string, + title?: string, + duration: number = 5000, +): void { + const popover = getOrCreatePopover(); + + const titleHtml = title + ? `
+ ${title} + +
` + : ''; + + popover.innerHTML = ` + ${titleHtml} +
${content}
+ `; + + popover.classList.remove('visible'); + + requestAnimationFrame(() => { + positionPopover(popover, x, y); + popover.classList.add('visible'); + }); + + if (hideTimeout) { + clearTimeout(hideTimeout); + } + + if (duration > 0) { + hideTimeout = setTimeout(() => { + hidePopover(); + }, duration); + } +} + +export function hidePopover(): void { + const popover = document.getElementById(POPOVER_ID); + if (popover) { + popover.classList.remove('visible'); + } + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } +} + +export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void { + const content = ` +
输入时间戳
+
${timestamp}
+
转换结果
+
${result}
+ `; + showPopover(x, y, content, '⏰ 时间戳转换'); +} + +export function showTextStatsResult( + x: number, + y: number, + text: string, + stats: { characters: number; words: number; lines: number; bytes: number }, +): void { + const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text; + const content = ` +
选中文本
+
${truncatedText}
+
+
+
字符
+
${stats.characters}
+
+
+
单词
+
${stats.words}
+
+
+
行数
+
${stats.lines}
+
+
+
字节
+
${stats.bytes}
+
+
+ `; + showPopover(x, y, content, '📊 文本统计'); +} 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 04/14] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20React=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B1=82=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=95=B0=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); +} From c1890960ea7d06292c14d41607d5df4f857efc17 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:54:51 +0800 Subject: [PATCH 05/14] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=20background?= =?UTF-8?q?=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=E4=B8=8E=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E6=83=85=E5=86=B5=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 entrypoints/__tests__/background.test.ts (12个测试用例) - 超长文本截断限制 (>10000字符) - Base64 内联图片拦截并返回错误提示 - 更新 parseContextMenuClick 返回 ParseResult 格式 - 更新测试匹配新的返回格式 - lint/typecheck/test 全部通过 (579个测试) --- entrypoints/__tests__/background.test.ts | 173 +++++++++++++++++++++++ entrypoints/background.ts | 10 +- utils/__tests__/contextMenu.test.ts | 78 +++++++--- utils/contextMenu.ts | 46 ++++-- 4 files changed, 278 insertions(+), 29 deletions(-) create mode 100644 entrypoints/__tests__/background.test.ts diff --git a/entrypoints/__tests__/background.test.ts b/entrypoints/__tests__/background.test.ts new file mode 100644 index 0000000..5304b2a --- /dev/null +++ b/entrypoints/__tests__/background.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + createAllContextMenus, + parseContextMenuClick, + CONTEXT_MENU_CONFIGS, + MAX_PAYLOAD_LENGTH, +} from '@/utils/contextMenu'; +import { MessageAction } from '@/utils/messages'; + +describe('background 菜单注册与分流', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('菜单注册', () => { + it('应该调用 createAllContextMenus 创建所有菜单项', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledTimes(CONTEXT_MENU_CONFIGS.length); + }); + + it('应该创建父级菜单 Testing Tools', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'testing-tools-parent', + title: 'Testing Tools', + }), + ); + }); + + it('应该创建 JWT 解析子菜单', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'jwt', + title: '🔑 解析 JWT', + parentId: 'testing-tools-parent', + }), + ); + }); + + it('应该创建图片二维码识别子菜单', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'qrCode-image', + title: '🖼️ 识别图中的二维码', + contexts: ['image'], + }), + ); + }); + }); + + describe('菜单点击解析', () => { + const createMockOnClickData = ( + overrides: Partial = {}, + ): chrome.contextMenus.OnClickData => ({ + menuItemId: 'test', + editable: false, + pageUrl: 'https://example.com', + ...overrides, + }); + + it('当有 selectionText 时应返回对应的 featureKey 和 payload', () => { + const info = createMockOnClickData({ + menuItemId: 'jwt', + selectionText: 'test-token', + }); + + const result = parseContextMenuClick('jwt', info); + + expect(result).toEqual({ + success: true, + data: { featureKey: 'jwt', payload: 'test-token' }, + }); + }); + + it('当点击 qrCode-image 时应返回 qrCode 功能和 srcUrl', () => { + const info = createMockOnClickData({ + menuItemId: 'qrCode-image', + srcUrl: 'https://example.com/image.png', + }); + + const result = parseContextMenuClick('qrCode-image', info); + + expect(result).toEqual({ + success: true, + data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' }, + }); + }); + + it('当图片为 Base64 内联图时应返回错误', () => { + const info = createMockOnClickData({ + menuItemId: 'qrCode-image', + srcUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + }); + + const result = parseContextMenuClick('qrCode-image', info); + + expect(result).toEqual({ + success: false, + error: '无法识别 Base64 内联图片,请使用图片文件 URL', + }); + }); + + it('当没有 selectionText 和 srcUrl 时应返回错误', () => { + const info = createMockOnClickData({ + menuItemId: 'unknown', + pageUrl: undefined, + }); + + const result = parseContextMenuClick('unknown', info); + + expect(result).toEqual({ + success: false, + error: '无法获取有效数据', + }); + }); + + it('当文本超过最大长度限制时应截断', () => { + const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000); + const info = createMockOnClickData({ + menuItemId: 'textStatistics', + 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({ + menuItemId: 'textStatistics', + selectionText: shortText, + }); + + const result = parseContextMenuClick('textStatistics', info); + + expect(result).toEqual({ + success: true, + data: { featureKey: 'textStatistics', payload: 'short text' }, + }); + }); + + it('应该正确处理页面 URL 菜单点击', () => { + const info = createMockOnClickData({ + menuItemId: 'storageCleaner', + pageUrl: 'https://example.com/page', + }); + + const result = parseContextMenuClick('storageCleaner', info); + + expect(result).toEqual({ + success: true, + data: { featureKey: 'storageCleaner', payload: 'https://example.com/page' }, + }); + }); + }); + + describe('消息类型定义', () => { + it('CONTEXT_MENU_CLICKED 消息类型应正确定义', () => { + expect(MessageAction.CONTEXT_MENU_CLICKED).toBe('contextMenuClicked'); + }); + }); +}); diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 97d08d3..da73d77 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -10,9 +10,15 @@ export default defineBackground(() => { browser.contextMenus.onClicked.addListener(async (info, _tab) => { const result = parseContextMenuClick(info.menuItemId as string, info); - if (!result) return; - const { featureKey, payload } = result; + if (!result.success || !result.data) { + if (result.error) { + console.warn('[Context Menu]', result.error); + } + return; + } + + const { featureKey, payload } = result.data; try { const sidePanelState = await browser.storage.local.get('sidePanelOpen'); diff --git a/utils/__tests__/contextMenu.test.ts b/utils/__tests__/contextMenu.test.ts index 887b490..50847e8 100644 --- a/utils/__tests__/contextMenu.test.ts +++ b/utils/__tests__/contextMenu.test.ts @@ -3,6 +3,7 @@ import { CONTEXT_MENU_CONFIGS, createAllContextMenus, parseContextMenuClick, + MAX_PAYLOAD_LENGTH, } from '@/utils/contextMenu'; describe('contextMenu', () => { @@ -96,19 +97,33 @@ describe('contextMenu', () => { const result = parseContextMenuClick('qrCode-image', info); expect(result).toEqual({ - featureKey: 'qrCode', - payload: 'https://example.com/image.png', + success: true, + data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' }, }); }); - it('当点击 qrCode-image 菜单但没有 srcUrl 时应返回空字符串', () => { + it('当图片为 Base64 内联图时应返回错误', () => { + const info = createMockOnClickData({ + srcUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + }); + + const result = parseContextMenuClick('qrCode-image', info); + + expect(result).toEqual({ + success: false, + error: '无法识别 Base64 内联图片,请使用图片文件 URL', + }); + }); + + it('当点击 qrCode-image 菜单但没有 srcUrl 时应返回成功', () => { const info = createMockOnClickData({}); const result = parseContextMenuClick('qrCode-image', info); expect(result).toEqual({ - featureKey: 'qrCode', - payload: '', + success: true, + data: { featureKey: 'qrCode', payload: '' }, }); }); @@ -120,8 +135,8 @@ describe('contextMenu', () => { const result = parseContextMenuClick('qrCode-page', info); expect(result).toEqual({ - featureKey: 'qrCode', - payload: 'https://example.com/page', + success: true, + data: { featureKey: 'qrCode', payload: 'https://example.com/page' }, }); }); @@ -133,8 +148,8 @@ describe('contextMenu', () => { const result = parseContextMenuClick('jwt', info); expect(result).toEqual({ - featureKey: 'jwt', - payload: 'selected text', + success: true, + data: { featureKey: 'jwt', payload: 'selected text' }, }); }); @@ -146,8 +161,8 @@ describe('contextMenu', () => { const result = parseContextMenuClick('timestamp', info); expect(result).toEqual({ - featureKey: 'timestamp', - payload: '1234567890', + success: true, + data: { featureKey: 'timestamp', payload: '1234567890' }, }); }); @@ -159,19 +174,22 @@ describe('contextMenu', () => { const result = parseContextMenuClick('storageCleaner', info); expect(result).toEqual({ - featureKey: 'storageCleaner', - payload: 'https://example.com', + success: true, + data: { featureKey: 'storageCleaner', payload: 'https://example.com' }, }); }); - it('当没有 selectionText 和 pageUrl 时应返回 null', () => { + it('当没有 selectionText 和 pageUrl 时应返回错误', () => { const info = createMockOnClickData({ pageUrl: undefined, }); const result = parseContextMenuClick('someMenu', info); - expect(result).toBeNull(); + expect(result).toEqual({ + success: false, + error: '无法获取有效数据', + }); }); it('selectionText 优先于 pageUrl', () => { @@ -183,8 +201,34 @@ describe('contextMenu', () => { const result = parseContextMenuClick('jwt', info); expect(result).toEqual({ - featureKey: 'jwt', - payload: 'selected text', + 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' }, }); }); }); diff --git a/utils/contextMenu.ts b/utils/contextMenu.ts index 1de7b8a..288de5a 100644 --- a/utils/contextMenu.ts +++ b/utils/contextMenu.ts @@ -12,8 +12,16 @@ export interface ContextMenuClickedInfo { payload: string; } +export interface ParseResult { + success: boolean; + data?: ContextMenuClickedInfo; + error?: string; +} + const PARENT_MENU_ID = 'testing-tools-parent'; +export const MAX_PAYLOAD_LENGTH = 10000; + export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [ { id: PARENT_MENU_ID, @@ -78,36 +86,54 @@ export function createAllContextMenus(): void { export function parseContextMenuClick( menuItemId: string, info: chrome.contextMenus.OnClickData, -): ContextMenuClickedInfo | null { +): ParseResult { const featureKey = menuItemId as PageType; if (menuItemId === 'qrCode-image') { + const srcUrl = info.srcUrl || ''; + + if (srcUrl.startsWith('data:image/')) { + return { + success: false, + error: '无法识别 Base64 内联图片,请使用图片文件 URL', + }; + } + return { - featureKey: 'qrCode', - payload: info.srcUrl || '', + success: true, + data: { featureKey: 'qrCode', payload: srcUrl }, }; } if (menuItemId === 'qrCode-page') { return { - featureKey: 'qrCode', - payload: info.pageUrl || '', + success: true, + data: { featureKey: 'qrCode', payload: info.pageUrl || '' }, }; } 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 { - featureKey, - payload: info.selectionText, + success: true, + data: { featureKey, payload: text }, }; } if (info.pageUrl) { return { - featureKey, - payload: info.pageUrl, + success: true, + data: { featureKey, payload: info.pageUrl }, }; } - return null; + return { success: false, error: '无法获取有效数据' }; } From 8ec584c85f98c2f43c23f6e67e4ce3da18071742 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 21:00:11 +0800 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E8=B7=B3=E8=BD=AC=E5=88=B0=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E9=A1=B5=E9=9D=A2=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html' - WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html --- entrypoints/background.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entrypoints/background.ts b/entrypoints/background.ts index da73d77..d1b15f6 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -32,7 +32,7 @@ export default defineBackground(() => { // sidepanel 未打开或无法通信,继续执行其他方案 } - const optionsUrl = chrome.runtime.getURL('/entrypoints/options/index.html'); + const optionsUrl = chrome.runtime.getURL('/options.html'); const params = new URLSearchParams({ feature: featureKey, payload }); await browser.tabs.create({ url: `${optionsUrl}?${params.toString()}` }); }); From 5868316c3a6daeb566c251341fe481b1adbd5f74 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 21:03:55 +0800 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E8=B7=B3=E8=BD=AC=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E4=B8=BA=20popup=20=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 fallback 页面从 options.html 改为 popup.html - popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面 - options 页面是独立设置页,不支持 URL 参数路由 --- entrypoints/background.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/entrypoints/background.ts b/entrypoints/background.ts index d1b15f6..48238e4 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -32,9 +32,9 @@ export default defineBackground(() => { // sidepanel 未打开或无法通信,继续执行其他方案 } - const optionsUrl = chrome.runtime.getURL('/options.html'); + const popupUrl = chrome.runtime.getURL('/popup.html'); const params = new URLSearchParams({ feature: featureKey, payload }); - await browser.tabs.create({ url: `${optionsUrl}?${params.toString()}` }); + await browser.tabs.create({ url: `${popupUrl}?${params.toString()}` }); }); // 监听扩展图标点击事件,打开侧边栏 From 8e56ebff6ec9d1343f8e75b3f21ab6960cf89f71 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 21:07:06 +0800 Subject: [PATCH 08/14] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=20openPopup()?= =?UTF-8?q?=20=E6=9B=BF=E4=BB=A3=20tabs.create()=20=E6=89=93=E5=BC=80?= =?UTF-8?q?=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗 - 先保存数据到 storage,然后打开 popup - RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面 - 移除之前错误的 tabs.create() 方式 --- entrypoints/background.ts | 13 ++++++++++--- providers/RouterProvider.tsx | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 48238e4..407cb90 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -2,6 +2,7 @@ import '../.wxt/types/imports.d.ts'; import { browser } from 'wxt/browser'; import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu'; +import { saveContextMenuData } from '@/utils/useContextMenuData'; export default defineBackground(() => { browser.runtime.onInstalled.addListener(() => { @@ -32,9 +33,15 @@ export default defineBackground(() => { // sidepanel 未打开或无法通信,继续执行其他方案 } - const popupUrl = chrome.runtime.getURL('/popup.html'); - const params = new URLSearchParams({ feature: featureKey, payload }); - await browser.tabs.create({ url: `${popupUrl}?${params.toString()}` }); + // 保存数据到 storage,popup 打开后会读取 + await saveContextMenuData({ featureKey, payload }); + + // 打开 popup 弹窗 + try { + await browser.action.openPopup(); + } catch (err) { + console.error('[Context Menu] 打开 popup 失败:', err); + } }); // 监听扩展图标点击事件,打开侧边栏 diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index 1f72adf..a3741a2 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -14,7 +14,7 @@ import { getDefaultPageOrder, getDefaultVisibleFeatureKeys, } from '@/config/features'; -import { saveContextMenuData } from '@/utils/useContextMenuData'; +import { saveContextMenuData, clearContextMenuData } from '@/utils/useContextMenuData'; /** * 校验是否为合法的页面类型 @@ -204,8 +204,24 @@ export function RouterProvider({ url.searchParams.delete('feature'); url.searchParams.delete('payload'); window.history.replaceState({}, '', url.toString()); + return; } } + + // 检查 storage 中的右键菜单待处理数据(用于 openPopup 场景) + storageUtil + .get('contextMenu/pendingData', undefined) + .then((pendingData) => { + if ( + pendingData && + isValidPage(pendingData.featureKey) && + Date.now() - pendingData.timestamp < 5000 + ) { + navigateTo(pendingData.featureKey as PageType); + clearContextMenuData().catch(console.error); + } + }) + .catch(console.error); } }) .catch(console.error); From 71995037dd8f121fcdfd47cfbf565793808e3878 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 21:09:47 +0800 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E6=95=B0=E6=8D=AE=E6=9C=AA=E4=BC=A0?= =?UTF-8?q?=E9=80=92=E5=88=B0=E7=9B=AE=E6=A0=87=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouterProvider 不再提前清除 pendingData - 让目标页面的 useContextMenuData 来消费和清除数据 - 这样确保 TextStatistics 等页面能正确接收选中的文本 --- providers/RouterProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index a3741a2..fb7a9ee 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -14,7 +14,7 @@ import { getDefaultPageOrder, getDefaultVisibleFeatureKeys, } from '@/config/features'; -import { saveContextMenuData, clearContextMenuData } from '@/utils/useContextMenuData'; +import { saveContextMenuData } from '@/utils/useContextMenuData'; /** * 校验是否为合法的页面类型 @@ -218,7 +218,7 @@ export function RouterProvider({ Date.now() - pendingData.timestamp < 5000 ) { navigateTo(pendingData.featureKey as PageType); - clearContextMenuData().catch(console.error); + // 不在这里清除数据,让目标页面的 useContextMenuData 来消费和清除 } }) .catch(console.error); From 387a95d6a6b4e0af3be2f22603e0f7e5d74bdafb 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 21:15:43 +0800 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20popup=20?= =?UTF-8?q?=E5=B7=B2=E6=89=93=E5=BC=80=E6=97=B6=E5=8F=B3=E9=94=AE=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=B8=8D=E7=94=9F=E6=95=88=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听 - 当 pendingData 变化时,自动跳转到对应功能页面 - 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题 --- providers/RouterProvider.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index fb7a9ee..8e6c718 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -7,7 +7,7 @@ import { useRef, useState, } from 'react'; -import type { PageType, StorageSchema } from '@/types/storage'; +import type { PageType, StorageSchema, ContextMenuPendingData } from '@/types/storage'; import { storageUtil } from '@/utils/chromeStorage'; import { getAllFeatureKeys, @@ -288,6 +288,14 @@ export function RouterProvider({ setPageOrder(newOrder); } } + // 监听右键菜单数据变化,自动跳转到对应页面(用于 popup 已打开的场景) + if (changes['contextMenu/pendingData']) { + const newData = changes['contextMenu/pendingData'] + .newValue as ContextMenuPendingData | null; + if (newData && isValidPage(newData.featureKey) && Date.now() - newData.timestamp < 5000) { + setCurrentPage(newData.featureKey as PageType); + } + } }; chrome.storage.onChanged.addListener(handleStorageChange); From 8e920c2a94df58831bfb9e70e1fc7f5a5fa06ceb 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 21:19:39 +0800 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E5=9B=BE=E7=89=87=E4=BA=8C=E7=BB=B4?= =?UTF-8?q?=E7=A0=81=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码 - 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL - 自动下载图片并解析二维码,显示解析结果 --- pages/QrCode/QrCodeToUrlSection.tsx | 36 ++++++++++++++++++++++++++++- utils/qrCodeParser.ts | 21 +++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/pages/QrCode/QrCodeToUrlSection.tsx b/pages/QrCode/QrCodeToUrlSection.tsx index 1bc99ed..24f70cb 100644 --- a/pages/QrCode/QrCodeToUrlSection.tsx +++ b/pages/QrCode/QrCodeToUrlSection.tsx @@ -20,8 +20,9 @@ import ClearIcon from '@mui/icons-material/Clear'; import CopyButton from '@/components/CopyButton'; import { qrCodePageStyles } from '@/config/pageTheme'; import { useSnackbar } from '@/components/GlobalSnackbar'; -import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; +import { parseQrCodeFromFile, parseQrCodeFromUrl } from '@/utils/qrCodeParser'; import { useTranslation } from 'react-i18next'; +import { useContextMenuData } from '@/utils/useContextMenuData'; interface QrCodeToUrlSectionProps { expanded: boolean; @@ -46,6 +47,39 @@ const QrCodeToUrlSection = ({ const fileInputRef = useRef(null); + // 处理右键菜单传递的图片 URL + const handleContextMenuData = useCallback( + async (imageUrl: string) => { + try { + setParsing(true); + setParseError(''); + setParsedUrl(''); + setPreviewUrl(imageUrl); + + const result = await parseQrCodeFromUrl(imageUrl); + + if (result.success && result.data) { + setParsedUrl(result.data); + showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 }); + } else { + setParseError(result.error || t('qrCode:noQrDetected')); + showMessage(result.error || t('qrCode:noQrDetected'), { + severity: 'error', + autoHideDuration: 1000, + }); + } + } catch (error) { + console.error('解析二维码失败:', error); + showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); + } finally { + setParsing(false); + } + }, + [showMessage, t], + ); + + useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); + // 清理预览 URL,防止内存泄漏 useEffect(() => { return () => { diff --git a/utils/qrCodeParser.ts b/utils/qrCodeParser.ts index 0148c99..5727c28 100644 --- a/utils/qrCodeParser.ts +++ b/utils/qrCodeParser.ts @@ -35,3 +35,24 @@ export async function parseQrCodeFromFile(file: File): Promise { + try { + const response = await fetch(imageUrl); + if (!response.ok) { + return { success: false, error: '无法下载图片' }; + } + + const blob = await response.blob(); + const file = new File([blob], 'qrcode.jpg', { type: blob.type || 'image/jpeg' }); + + return await parseQrCodeFromFile(file); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + return { success: false, error: `下载图片失败: ${errorMsg}` }; + } +} From 00fe3eddd9b5594880aa719e3fafe919ed5c0a52 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 21:27:36 +0800 Subject: [PATCH 12/14] =?UTF-8?q?refactor:=20=E6=9A=82=E6=97=B6=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E5=9B=BE=E7=89=87=E4=BA=8C=E7=BB=B4=E7=A0=81=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 qrCode-image 右键菜单配置 - 删除 parseQrCodeFromUrl 函数 - 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码 - 更新相关测试用例 --- entrypoints/__tests__/background.test.ts | 37 ++--------------- pages/QrCode/QrCodeToUrlSection.tsx | 36 +--------------- utils/__tests__/contextMenu.test.ts | 52 ++---------------------- utils/contextMenu.ts | 22 ---------- utils/qrCodeParser.ts | 21 ---------- 5 files changed, 8 insertions(+), 160 deletions(-) diff --git a/entrypoints/__tests__/background.test.ts b/entrypoints/__tests__/background.test.ts index 5304b2a..21d5d32 100644 --- a/entrypoints/__tests__/background.test.ts +++ b/entrypoints/__tests__/background.test.ts @@ -42,14 +42,14 @@ describe('background 菜单注册与分流', () => { ); }); - it('应该创建图片二维码识别子菜单', () => { + it('应该创建网页链接转二维码子菜单', () => { createAllContextMenus(); expect(chrome.contextMenus.create).toHaveBeenCalledWith( expect.objectContaining({ - id: 'qrCode-image', - title: '🖼️ 识别图中的二维码', - contexts: ['image'], + id: 'qrCode-page', + title: '🔗 网页链接转二维码', + contexts: ['page'], }), ); }); @@ -79,35 +79,6 @@ describe('background 菜单注册与分流', () => { }); }); - it('当点击 qrCode-image 时应返回 qrCode 功能和 srcUrl', () => { - const info = createMockOnClickData({ - menuItemId: 'qrCode-image', - srcUrl: 'https://example.com/image.png', - }); - - const result = parseContextMenuClick('qrCode-image', info); - - expect(result).toEqual({ - success: true, - data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' }, - }); - }); - - it('当图片为 Base64 内联图时应返回错误', () => { - const info = createMockOnClickData({ - menuItemId: 'qrCode-image', - srcUrl: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - }); - - const result = parseContextMenuClick('qrCode-image', info); - - expect(result).toEqual({ - success: false, - error: '无法识别 Base64 内联图片,请使用图片文件 URL', - }); - }); - it('当没有 selectionText 和 srcUrl 时应返回错误', () => { const info = createMockOnClickData({ menuItemId: 'unknown', diff --git a/pages/QrCode/QrCodeToUrlSection.tsx b/pages/QrCode/QrCodeToUrlSection.tsx index 24f70cb..1bc99ed 100644 --- a/pages/QrCode/QrCodeToUrlSection.tsx +++ b/pages/QrCode/QrCodeToUrlSection.tsx @@ -20,9 +20,8 @@ import ClearIcon from '@mui/icons-material/Clear'; import CopyButton from '@/components/CopyButton'; import { qrCodePageStyles } from '@/config/pageTheme'; import { useSnackbar } from '@/components/GlobalSnackbar'; -import { parseQrCodeFromFile, parseQrCodeFromUrl } from '@/utils/qrCodeParser'; +import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { useTranslation } from 'react-i18next'; -import { useContextMenuData } from '@/utils/useContextMenuData'; interface QrCodeToUrlSectionProps { expanded: boolean; @@ -47,39 +46,6 @@ const QrCodeToUrlSection = ({ const fileInputRef = useRef(null); - // 处理右键菜单传递的图片 URL - const handleContextMenuData = useCallback( - async (imageUrl: string) => { - try { - setParsing(true); - setParseError(''); - setParsedUrl(''); - setPreviewUrl(imageUrl); - - const result = await parseQrCodeFromUrl(imageUrl); - - if (result.success && result.data) { - setParsedUrl(result.data); - showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 }); - } else { - setParseError(result.error || t('qrCode:noQrDetected')); - showMessage(result.error || t('qrCode:noQrDetected'), { - severity: 'error', - autoHideDuration: 1000, - }); - } - } catch (error) { - console.error('解析二维码失败:', error); - showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); - } finally { - setParsing(false); - } - }, - [showMessage, t], - ); - - useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); - // 清理预览 URL,防止内存泄漏 useEffect(() => { return () => { diff --git a/utils/__tests__/contextMenu.test.ts b/utils/__tests__/contextMenu.test.ts index 50847e8..49c09f9 100644 --- a/utils/__tests__/contextMenu.test.ts +++ b/utils/__tests__/contextMenu.test.ts @@ -12,8 +12,8 @@ describe('contextMenu', () => { }); describe('CONTEXT_MENU_CONFIGS', () => { - it('应该包含 8 个菜单项配置', () => { - expect(CONTEXT_MENU_CONFIGS).toHaveLength(8); + it('应该包含 7 个菜单项配置', () => { + expect(CONTEXT_MENU_CONFIGS).toHaveLength(7); }); it('应该有一个父级菜单项 Testing Tools', () => { @@ -36,14 +36,6 @@ describe('contextMenu', () => { ]); }); - it('应该有 1 个 image 上下文的子菜单', () => { - const imageMenus = CONTEXT_MENU_CONFIGS.filter( - (c) => c.contexts[0] === 'image' && c.parentId === 'testing-tools-parent', - ); - expect(imageMenus).toHaveLength(1); - expect(imageMenus[0].id).toBe('qrCode-image'); - }); - it('应该有 2 个 page 上下文的子菜单', () => { const pageMenus = CONTEXT_MENU_CONFIGS.filter( (c) => c.contexts[0] === 'page' && c.parentId === 'testing-tools-parent', @@ -57,7 +49,7 @@ describe('contextMenu', () => { it('应该为每个配置调用 chrome.contextMenus.create', () => { createAllContextMenus(); - expect(chrome.contextMenus.create).toHaveBeenCalledTimes(8); + expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7); }); it('应该使用正确的参数创建菜单项', () => { @@ -89,44 +81,6 @@ describe('contextMenu', () => { ...overrides, }); - it('当点击 qrCode-image 菜单时应返回 qrCode 功能和 srcUrl', () => { - const info = createMockOnClickData({ - srcUrl: 'https://example.com/image.png', - }); - - const result = parseContextMenuClick('qrCode-image', info); - - expect(result).toEqual({ - success: true, - data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' }, - }); - }); - - it('当图片为 Base64 内联图时应返回错误', () => { - const info = createMockOnClickData({ - srcUrl: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - }); - - const result = parseContextMenuClick('qrCode-image', info); - - expect(result).toEqual({ - success: false, - error: '无法识别 Base64 内联图片,请使用图片文件 URL', - }); - }); - - it('当点击 qrCode-image 菜单但没有 srcUrl 时应返回成功', () => { - const info = createMockOnClickData({}); - - const result = parseContextMenuClick('qrCode-image', info); - - expect(result).toEqual({ - success: true, - data: { featureKey: 'qrCode', payload: '' }, - }); - }); - it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => { const info = createMockOnClickData({ pageUrl: 'https://example.com/page', diff --git a/utils/contextMenu.ts b/utils/contextMenu.ts index 288de5a..dc56b9a 100644 --- a/utils/contextMenu.ts +++ b/utils/contextMenu.ts @@ -52,12 +52,6 @@ export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [ contexts: [chrome.contextMenus.ContextType.SELECTION], parentId: PARENT_MENU_ID, }, - { - id: 'qrCode-image', - title: '🖼️ 识别图中的二维码', - contexts: [chrome.contextMenus.ContextType.IMAGE], - parentId: PARENT_MENU_ID, - }, { id: 'storageCleaner', title: '🧹 清理当前网站存储', @@ -89,22 +83,6 @@ export function parseContextMenuClick( ): ParseResult { const featureKey = menuItemId as PageType; - if (menuItemId === 'qrCode-image') { - const srcUrl = info.srcUrl || ''; - - if (srcUrl.startsWith('data:image/')) { - return { - success: false, - error: '无法识别 Base64 内联图片,请使用图片文件 URL', - }; - } - - return { - success: true, - data: { featureKey: 'qrCode', payload: srcUrl }, - }; - } - if (menuItemId === 'qrCode-page') { return { success: true, diff --git a/utils/qrCodeParser.ts b/utils/qrCodeParser.ts index 5727c28..0148c99 100644 --- a/utils/qrCodeParser.ts +++ b/utils/qrCodeParser.ts @@ -35,24 +35,3 @@ export async function parseQrCodeFromFile(file: File): Promise { - try { - const response = await fetch(imageUrl); - if (!response.ok) { - return { success: false, error: '无法下载图片' }; - } - - const blob = await response.blob(); - const file = new File([blob], 'qrcode.jpg', { type: blob.type || 'image/jpeg' }); - - return await parseQrCodeFromFile(file); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - return { success: false, error: `下载图片失败: ${errorMsg}` }; - } -} From e9a6fa3dd837c1eb89b61f5d3610b60607c8334d 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 21:32:35 +0800 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E6=97=B6=E9=97=B4=E6=88=B3=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 useTimestampConverter 中添加 useContextMenuData hook - 智能识别输入是时间戳还是日期时间字符串 - 自动切换到对应模式并执行转换 - 时间戳自动识别秒/毫秒单位 --- pages/Timestamp/useTimestampConverter.ts | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pages/Timestamp/useTimestampConverter.ts b/pages/Timestamp/useTimestampConverter.ts index c86ba18..b7fa2fb 100644 --- a/pages/Timestamp/useTimestampConverter.ts +++ b/pages/Timestamp/useTimestampConverter.ts @@ -3,6 +3,7 @@ import dayjs from '@/utils/dayjs'; import type { UnitType, ZoneType } from '@/config/pageTheme'; import { DATE_FORMAT } from '@/config/pageTheme'; import { useTranslation } from 'react-i18next'; +import { useContextMenuData } from '@/utils/useContextMenuData'; export interface UseTimestampConverterReturn { // State @@ -23,6 +24,15 @@ export interface UseTimestampConverterReturn { convert: () => void; } +/** + * 判断输入是否为时间戳(纯数字或长度 >= 10 的数字字符串) + */ +function isTimestampLike(input: string): boolean { + const trimmed = input.trim(); + if (!/^\d+$/.test(trimmed)) return false; + return trimmed.length >= 10; +} + export function useTimestampConverter(): UseTimestampConverterReturn { const { t } = useTranslation(['timestamp']); const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt'); @@ -63,6 +73,45 @@ export function useTimestampConverter(): UseTimestampConverterReturn { } }, [mode, tsInput, dtInput, unit, zone, t]); + // 处理右键菜单传递的数据 + const handleContextMenuData = useCallback((payload: string) => { + const trimmed = payload.trim(); + if (isTimestampLike(trimmed)) { + // 看起来是时间戳,切换到 ts2dt 模式 + setMode('ts2dt'); + setTsInput(trimmed); + // 如果是 13 位毫秒级时间戳,自动选择 ms 单位 + const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's'; + setUnit(detectedUnit); + // 直接执行转换 + const num = Number(trimmed); + if (!isNaN(num)) { + const d = detectedUnit === 'ms' ? dayjs(num) : dayjs.unix(num); + if (d.isValid()) { + setError(''); + setResult(d.tz('Asia/Shanghai').format(DATE_FORMAT)); + } + } + } else { + // 尝试作为日期时间解析 + const d = dayjs(trimmed); + if (d.isValid()) { + setMode('dt2ts'); + setDtInput(d.format(DATE_FORMAT)); + // 直接执行转换 + const ms = d.valueOf(); + setError(''); + setResult(String(ms)); + } else { + // 无法识别,作为时间戳处理 + setMode('ts2dt'); + setTsInput(trimmed); + } + } + }, []); + + useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData }); + const handleUseNow = useCallback( (now: number) => { if (mode === 'ts2dt') { From 0427f16cc9d0e0e999bcf9bad73b945890f88ea9 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 21:39:33 +0800 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E5=8A=9F=E8=83=BD=E7=9A=84=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E9=80=BB=E8=BE=91=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai 2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据 3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用 4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID --- entrypoints/background.ts | 2 + pages/Timestamp/useTimestampConverter.ts | 67 +++++++++++++----------- providers/RouterProvider.tsx | 10 ++-- utils/contextMenu.ts | 22 +++++--- utils/useContextMenuData.ts | 6 ++- 5 files changed, 62 insertions(+), 45 deletions(-) diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 407cb90..c835720 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -41,6 +41,8 @@ export default defineBackground(() => { await browser.action.openPopup(); } catch (err) { console.error('[Context Menu] 打开 popup 失败:', err); + // 打开失败时清除残留数据,避免下次打开 popup 时误触发 + await chrome.storage.local.remove('contextMenu/pendingData'); } }); diff --git a/pages/Timestamp/useTimestampConverter.ts b/pages/Timestamp/useTimestampConverter.ts index b7fa2fb..4b378a1 100644 --- a/pages/Timestamp/useTimestampConverter.ts +++ b/pages/Timestamp/useTimestampConverter.ts @@ -74,41 +74,44 @@ export function useTimestampConverter(): UseTimestampConverterReturn { }, [mode, tsInput, dtInput, unit, zone, t]); // 处理右键菜单传递的数据 - const handleContextMenuData = useCallback((payload: string) => { - const trimmed = payload.trim(); - if (isTimestampLike(trimmed)) { - // 看起来是时间戳,切换到 ts2dt 模式 - setMode('ts2dt'); - setTsInput(trimmed); - // 如果是 13 位毫秒级时间戳,自动选择 ms 单位 - const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's'; - setUnit(detectedUnit); - // 直接执行转换 - const num = Number(trimmed); - if (!isNaN(num)) { - const d = detectedUnit === 'ms' ? dayjs(num) : dayjs.unix(num); - if (d.isValid()) { - setError(''); - setResult(d.tz('Asia/Shanghai').format(DATE_FORMAT)); - } - } - } else { - // 尝试作为日期时间解析 - const d = dayjs(trimmed); - if (d.isValid()) { - setMode('dt2ts'); - setDtInput(d.format(DATE_FORMAT)); - // 直接执行转换 - const ms = d.valueOf(); - setError(''); - setResult(String(ms)); - } else { - // 无法识别,作为时间戳处理 + const handleContextMenuData = useCallback( + (payload: string) => { + const trimmed = payload.trim(); + if (isTimestampLike(trimmed)) { + // 看起来是时间戳,切换到 ts2dt 模式 setMode('ts2dt'); setTsInput(trimmed); + // 如果是 13 位毫秒级时间戳,自动选择 ms 单位 + const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's'; + setUnit(detectedUnit); + // 直接执行转换 + const num = Number(trimmed); + if (!isNaN(num)) { + const d = detectedUnit === 'ms' ? dayjs(num) : dayjs.unix(num); + if (d.isValid()) { + setError(''); + setResult(d.tz(zone).format(DATE_FORMAT)); + } + } + } else { + // 尝试作为日期时间解析 + const d = dayjs(trimmed); + if (d.isValid()) { + setMode('dt2ts'); + setDtInput(d.format(DATE_FORMAT)); + // 直接执行转换 + const ms = d.valueOf(); + setError(''); + setResult(String(ms)); + } else { + // 无法识别,作为时间戳处理 + setMode('ts2dt'); + setTsInput(trimmed); + } } - } - }, []); + }, + [zone], + ); useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData }); diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index 8e6c718..10017d2 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -14,7 +14,7 @@ import { getDefaultPageOrder, getDefaultVisibleFeatureKeys, } from '@/config/features'; -import { saveContextMenuData } from '@/utils/useContextMenuData'; +import { saveContextMenuData, CONTEXT_MENU_DATA_EXPIRY_MS } from '@/utils/useContextMenuData'; /** * 校验是否为合法的页面类型 @@ -215,7 +215,7 @@ export function RouterProvider({ if ( pendingData && isValidPage(pendingData.featureKey) && - Date.now() - pendingData.timestamp < 5000 + Date.now() - pendingData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS ) { navigateTo(pendingData.featureKey as PageType); // 不在这里清除数据,让目标页面的 useContextMenuData 来消费和清除 @@ -292,7 +292,11 @@ export function RouterProvider({ if (changes['contextMenu/pendingData']) { const newData = changes['contextMenu/pendingData'] .newValue as ContextMenuPendingData | null; - if (newData && isValidPage(newData.featureKey) && Date.now() - newData.timestamp < 5000) { + if ( + newData && + isValidPage(newData.featureKey) && + Date.now() - newData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS + ) { setCurrentPage(newData.featureKey as PageType); } } diff --git a/utils/contextMenu.ts b/utils/contextMenu.ts index dc56b9a..5452a9f 100644 --- a/utils/contextMenu.ts +++ b/utils/contextMenu.ts @@ -22,6 +22,19 @@ const PARENT_MENU_ID = 'testing-tools-parent'; export const MAX_PAYLOAD_LENGTH = 10000; +/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */ +const MENU_ID_TO_PAGE_TYPE: Record = { + '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, @@ -81,14 +94,7 @@ export function parseContextMenuClick( menuItemId: string, info: chrome.contextMenus.OnClickData, ): ParseResult { - const featureKey = menuItemId as PageType; - - if (menuItemId === 'qrCode-page') { - return { - success: true, - data: { featureKey: 'qrCode', payload: info.pageUrl || '' }, - }; - } + const featureKey = getMenuPageType(menuItemId); if (info.selectionText) { const text = info.selectionText; diff --git a/utils/useContextMenuData.ts b/utils/useContextMenuData.ts index cdc78cd..71d353e 100644 --- a/utils/useContextMenuData.ts +++ b/utils/useContextMenuData.ts @@ -3,7 +3,9 @@ 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 const CONTEXT_MENU_DATA_EXPIRY_MS = 5000; export interface UseContextMenuDataOptions { /** 当前页面的功能标识 */ @@ -29,7 +31,7 @@ export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOpt if (data.featureKey !== featureKey) return; - if (Date.now() - data.timestamp > DATA_EXPIRY_MS) { + if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) { await storageUtil.remove(STORAGE_KEY); return; }