feat(qrcode): 实现图片转二维码右键菜单功能

- 添加图片类型的右键菜单配置
- 修改 parseContextMenuClick 支持 srcUrl 参数
- 实现图片URL自动解析功能
- 检测图片URL自动切换到解析模式
- 更新测试用例覆盖新功能
- 新增国际化文案
This commit is contained in:
雨霖铃
2026-05-30 20:58:28 +08:00
parent 306600a35c
commit 5be15301b5
5 changed files with 149 additions and 33 deletions
+4
View File
@@ -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"
+89 -29
View File
@@ -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 });
+40 -3
View File
@@ -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' },
});
});
});
});
+15
View File
@@ -25,6 +25,7 @@ export const MAX_PAYLOAD_LENGTH = 10000;
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
'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;
+1 -1
View File
@@ -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/'],
});