From 5be15301b534813bc698eaaf1ab9452df27a9221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Sat, 30 May 2026 20:58:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(qrcode):=20=E5=AE=9E=E7=8E=B0=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=BD=AC=E4=BA=8C=E7=BB=B4=E7=A0=81=E5=8F=B3=E9=94=AE?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加图片类型的右键菜单配置 - 修改 parseContextMenuClick 支持 srcUrl 参数 - 实现图片URL自动解析功能 - 检测图片URL自动切换到解析模式 - 更新测试用例覆盖新功能 - 新增国际化文案 --- public/_locales/zh/messages.json | 4 + src/pages/QrCode/hooks/useQrCode.ts | 118 ++++++++++++++++++------ src/utils/__tests__/contextMenu.test.ts | 43 ++++++++- src/utils/contextMenu.ts | 15 +++ web-ext.config.ts | 2 +- 5 files changed, 149 insertions(+), 33 deletions(-) diff --git a/public/_locales/zh/messages.json b/public/_locales/zh/messages.json index 83eb2db..81dbbb0 100644 --- a/public/_locales/zh/messages.json +++ b/public/_locales/zh/messages.json @@ -616,6 +616,10 @@ "message": "解析结果将显示在此处", "description": "Translation key: qrCode_resultPlaceholder" }, + "qrCode_imageToQr": { + "message": "图片转二维码", + "description": "Translation key: qrCode_imageToQr" + }, "rightClickRestorer_loading": { "message": "正在加载...", "description": "Translation key: rightClickRestorer_loading" diff --git a/src/pages/QrCode/hooks/useQrCode.ts b/src/pages/QrCode/hooks/useQrCode.ts index aa5217b..ed94977 100644 --- a/src/pages/QrCode/hooks/useQrCode.ts +++ b/src/pages/QrCode/hooks/useQrCode.ts @@ -25,6 +25,13 @@ function isUrl(text: string): boolean { return domainPattern.test(trimmed); } +/** 检测URL是否为图片格式 */ +function isImageUrl(url: string): boolean { + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico']; + const lowerUrl = url.toLowerCase(); + return imageExtensions.some((ext) => lowerUrl.includes(ext)); +} + /** 生成二维码的核心逻辑 */ function generateQrCodeDataUrl(text: string): string { const trimmedText = text.trim(); @@ -178,37 +185,90 @@ export function useQrCode(): QrCodeContextValue { })); }, []); - /** 右键菜单传入URL时,自动生成二维码 */ - const handleContextMenuData = useCallback((payload: string) => { - setMode('generate'); + /** 从图片URL解析二维码 */ + const parseQrCodeFromUrl = useCallback( + async (imageUrl: string) => { + try { + setParserState((prev) => ({ + ...prev, + parsing: true, + parseError: '', + decodedResult: '', + previewUrl: imageUrl, + selectedFile: null, + })); - // 直接生成二维码,无需等待 - const qrCodeDataUrl = generateQrCodeDataUrl(payload); + // 从URL获取图片并转换为File对象 + const response = await fetch(imageUrl); + const blob = await response.blob(); + const file = new File([blob], 'qrcode-image.png', { type: blob.type }); - if (qrCodeDataUrl) { - // 生成成功,直接跳转到预览态 - setGeneratorState((prev) => ({ - ...prev, - step: 'preview', - textToEncode: payload, - savedText: payload.trim(), - qrCodeDataUrl, - generating: false, - inputError: '', - })); - } else { - // 生成失败,停留在输入态,显示文本供用户编辑 - setGeneratorState((prev) => ({ - ...prev, - step: 'input', - textToEncode: payload, - savedText: '', - qrCodeDataUrl: '', - generating: false, - inputError: '', - })); - } - }, []); + setParserState((prev) => ({ ...prev, selectedFile: file })); + + const result = await parseQrCodeFromFile(file); + + if (result.success && result.data) { + setParserState((prev) => ({ ...prev, decodedResult: result.data! })); + toast.success(t('qrCode:parseSuccess')); + } else { + const errorMsg = result.error || t('qrCode:noQrDetected'); + setParserState((prev) => ({ ...prev, parseError: errorMsg })); + toast.error(errorMsg); + } + } catch (error) { + console.error('解析图片二维码失败:', error); + const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError'); + setParserState((prev) => ({ ...prev, parseError: errorMsg })); + toast.error(errorMsg); + } finally { + setParserState((prev) => ({ ...prev, parsing: false })); + } + }, + [t], + ); + + /** 右键菜单传入URL时,自动生成二维码或解析图片 */ + const handleContextMenuData = useCallback( + (payload: string) => { + // 检测是否为图片URL,如果是则切换到解析模式 + if (isImageUrl(payload)) { + setMode('parse'); + void parseQrCodeFromUrl(payload); + return; + } + + // 非图片URL,生成二维码 + setMode('generate'); + + // 直接生成二维码,无需等待 + const qrCodeDataUrl = generateQrCodeDataUrl(payload); + + if (qrCodeDataUrl) { + // 生成成功,直接跳转到预览态 + setGeneratorState((prev) => ({ + ...prev, + step: 'preview', + textToEncode: payload, + savedText: payload.trim(), + qrCodeDataUrl, + generating: false, + inputError: '', + })); + } else { + // 生成失败,停留在输入态,显示文本供用户编辑 + setGeneratorState((prev) => ({ + ...prev, + step: 'input', + textToEncode: payload, + savedText: '', + qrCodeDataUrl: '', + generating: false, + inputError: '', + })); + } + }, + [parseQrCodeFromUrl], + ); useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); diff --git a/src/utils/__tests__/contextMenu.test.ts b/src/utils/__tests__/contextMenu.test.ts index 49c09f9..f4b34c1 100644 --- a/src/utils/__tests__/contextMenu.test.ts +++ b/src/utils/__tests__/contextMenu.test.ts @@ -12,8 +12,8 @@ describe('contextMenu', () => { }); describe('CONTEXT_MENU_CONFIGS', () => { - it('应该包含 7 个菜单项配置', () => { - expect(CONTEXT_MENU_CONFIGS).toHaveLength(7); + it('应该包含 8 个菜单项配置', () => { + expect(CONTEXT_MENU_CONFIGS).toHaveLength(8); }); it('应该有一个父级菜单项 Testing Tools', () => { @@ -43,13 +43,22 @@ describe('contextMenu', () => { expect(pageMenus).toHaveLength(2); expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']); }); + + 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'); + expect(imageMenus[0].title).toBe('🖼️ 图片转二维码'); + }); }); describe('createAllContextMenus', () => { it('应该为每个配置调用 chrome.contextMenus.create', () => { createAllContextMenus(); - expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7); + expect(chrome.contextMenus.create).toHaveBeenCalledTimes(8); }); it('应该使用正确的参数创建菜单项', () => { @@ -185,5 +194,33 @@ describe('contextMenu', () => { data: { featureKey: 'textStatistics', payload: 'short text' }, }); }); + + it('当点击 qrCode-image 菜单时应返回 qrCode 功能和图片URL', () => { + 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('srcUrl 优先于 selectionText 和 pageUrl', () => { + const info = createMockOnClickData({ + srcUrl: 'https://example.com/image.png', + selectionText: 'selected text', + pageUrl: 'https://example.com', + }); + + const result = parseContextMenuClick('qrCode-image', info); + + expect(result).toEqual({ + success: true, + data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' }, + }); + }); }); }); diff --git a/src/utils/contextMenu.ts b/src/utils/contextMenu.ts index 5452a9f..bde2e3b 100644 --- a/src/utils/contextMenu.ts +++ b/src/utils/contextMenu.ts @@ -25,6 +25,7 @@ export const MAX_PAYLOAD_LENGTH = 10000; /** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */ const MENU_ID_TO_PAGE_TYPE: Record = { 'qrCode-page': 'qrCode', + 'qrCode-image': 'qrCode', }; /** @@ -77,6 +78,12 @@ export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [ contexts: [chrome.contextMenus.ContextType.PAGE], parentId: PARENT_MENU_ID, }, + { + id: 'qrCode-image', + title: '🖼️ 图片转二维码', + contexts: [chrome.contextMenus.ContextType.IMAGE], + parentId: PARENT_MENU_ID, + }, ]; export function createAllContextMenus(): void { @@ -96,6 +103,14 @@ export function parseContextMenuClick( ): ParseResult { const featureKey = getMenuPageType(menuItemId); + // 处理图片 URL(右键点击图片时) + if (info.srcUrl) { + return { + success: true, + data: { featureKey, payload: info.srcUrl }, + }; + } + if (info.selectionText) { const text = info.selectionText; diff --git a/web-ext.config.ts b/web-ext.config.ts index 63bf5c3..a36ae97 100644 --- a/web-ext.config.ts +++ b/web-ext.config.ts @@ -1,6 +1,6 @@ import { defineWebExtConfig } from 'wxt'; export default defineWebExtConfig({ - startUrls: ['https://www.baidu.com', 'chrome://extensions/'], + startUrls: ['https://www.bing.com', 'chrome://extensions/'], chromiumArgs: ['chrome://extensions/'], });