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

- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过
This commit is contained in:
雨霖铃
2026-05-20 22:42:33 +08:00
parent bc560cc453
commit c5b51a9b33
6 changed files with 245 additions and 54 deletions
+3 -2
View File
@@ -40,8 +40,9 @@ export default defineBackground(() => {
try { try {
await browser.action.openPopup(); await browser.action.openPopup();
} catch (err) { } catch (err) {
console.error('[Context Menu] 打开 popup 失败:', err); // openPopup 在无活动窗口时会失败(如窗口失焦、特殊页面等)
// 打开失败时清除残留数据,避免下次打开 popup 时误触发 // 数据已保存到 storage,用户手动打开 popup 仍可正常使用
console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标:', err);
await chrome.storage.local.remove('contextMenu/pendingData'); await chrome.storage.local.remove('contextMenu/pendingData');
} }
}); });
+24 -7
View File
@@ -1,31 +1,48 @@
{ {
"pageTitle": "QR Code Tools", "pageTitle": "QR Code Tools",
"pageSubtitle": "Generate and parse QR codes", "pageSubtitle": "Generate and parse QR codes",
"generateMode": "Generate QR Code",
"parseMode": "Parse QR Code",
"urlToQr": "URL to QR Code", "urlToQr": "URL to QR Code",
"qrToUrl": "QR Code to URL", "qrToUrl": "QR Code to URL",
"urlInputLabel": "Enter URL",
"urlInputPlaceholder": "https://example.com", "urlInputLabel": "Enter URL or Text",
"urlInputPlaceholder": "Enter URL or text content, QR code will be generated automatically",
"generateButton": "Generate QR Code", "generateButton": "Generate QR Code",
"generating": "Generating...", "generating": "Generating...",
"qrCodeWillShow": "QR code will be shown here", "qrCodeWillShow": "QR code will be shown here",
"downloadButton": "Download QR Code", "downloadButton": "Download QR Code",
"copyQrButton": "Copy QR Code Image", "copyQrButton": "Copy QR Code Image",
"qrCodeSuccess": "QR code generated successfully", "qrCodeSuccess": "QR code generated successfully",
"qrCodeDownloadSuccess": "QR code downloaded successfully", "qrCodeDownloadSuccess": "QR code downloaded successfully",
"qrCodeCopySuccess": "QR code copied to clipboard", "qrCodeCopySuccess": "QR code copied to clipboard",
"selectImage": "Please select a QR code image", "selectImage": "Please select a QR code image",
"parseSuccess": "QR code parsed successfully", "parseSuccess": "QR code parsed successfully",
"noQrDetected": "No QR code detected", "noQrDetected": "No QR code detected, please ensure the image is clear and contains a QR code",
"parseError": "Failed to parse QR code, please try again", "parseError": "Failed to parse QR code, please try again",
"imagePasted": "Image pasted successfully", "generateError": "Failed to generate QR code, please check the input",
"copyError": "Copy failed, please try again",
"imagePasted": "Image pasted, parsing...",
"imagePasteError": "Failed to paste image, please try again", "imagePasteError": "Failed to paste image, please try again",
"imageCleared": "Image cleared", "imageCleared": "Image cleared",
"clickToUpload": "Click, drag, or paste to upload QR code image", "clickToUpload": "Click, drag, or paste to upload QR code image",
"supportFormats": "Supports PNG, JPG, WEBP formats", "supportFormats": "Supports PNG, JPG, WEBP, Base64 formats",
"parseButton": "Parse QR Code", "parseButton": "Parse QR Code",
"parsing": "Parsing...", "parsing": "Parsing...",
"resultLabel": "Parsing Result", "resultLabel": "Parsing Result",
"copyTooltip": "Copy", "copyTooltip": "Copy",
"enterUrlError": "Please enter a URL",
"clickToChange": "Click to change image" "enterUrlError": "Please enter URL or text",
"clickToChange": "Click to change image",
"pasteHint": "Supports Ctrl+V to paste images or Base64 strings",
"autoGenerateHint": "QR code will be generated automatically after input",
"autoParseHint": "QR code will be parsed automatically after upload"
} }
+24 -7
View File
@@ -1,31 +1,48 @@
{ {
"pageTitle": "二维码工具", "pageTitle": "二维码工具",
"pageSubtitle": "生成和解析二维码", "pageSubtitle": "生成和解析二维码",
"generateMode": "生成二维码",
"parseMode": "解析二维码",
"urlToQr": "URL 转二维码", "urlToQr": "URL 转二维码",
"qrToUrl": "二维码转 URL", "qrToUrl": "二维码转 URL",
"urlInputLabel": "输入 URL",
"urlInputPlaceholder": "https://example.com", "urlInputLabel": "输入 URL 或文本",
"urlInputPlaceholder": "请输入 URL 或文本内容,将自动生成二维码",
"generateButton": "生成二维码", "generateButton": "生成二维码",
"generating": "生成中...", "generating": "生成中...",
"qrCodeWillShow": "二维码将显示在这里", "qrCodeWillShow": "二维码将显示在这里",
"downloadButton": "下载二维码", "downloadButton": "下载二维码",
"copyQrButton": "复制二维码", "copyQrButton": "复制二维码",
"qrCodeSuccess": "二维码生成成功", "qrCodeSuccess": "二维码生成成功",
"qrCodeDownloadSuccess": "二维码下载成功", "qrCodeDownloadSuccess": "二维码下载成功",
"qrCodeCopySuccess": "二维码已复制到剪贴板", "qrCodeCopySuccess": "二维码已复制到剪贴板",
"selectImage": "请选择二维码图片", "selectImage": "请选择二维码图片",
"parseSuccess": "二维码解析成功", "parseSuccess": "二维码解析成功",
"noQrDetected": "未检测到二维码", "noQrDetected": "未检测到二维码,请确保图片清晰且包含二维码",
"parseError": "解析二维码失败,请重试", "parseError": "解析二维码失败,请重试",
"imagePasted": "图片粘贴成功", "generateError": "生成二维码失败,请检查输入内容",
"copyError": "复制失败,请重试",
"imagePasted": "图片粘贴成功,正在解析...",
"imagePasteError": "粘贴图片失败,请重试", "imagePasteError": "粘贴图片失败,请重试",
"imageCleared": "图片已清除", "imageCleared": "图片已清除",
"clickToUpload": "点击、拖拽或粘贴上传二维码图片", "clickToUpload": "点击、拖拽或粘贴上传二维码图片",
"supportFormats": "支持 PNG、JPG、WEBP 格式", "supportFormats": "支持 PNG、JPG、WEBP、Base64 格式",
"parseButton": "解析二维码", "parseButton": "解析二维码",
"parsing": "解析中...", "parsing": "解析中...",
"resultLabel": "解析结果", "resultLabel": "解析结果",
"copyTooltip": "复制", "copyTooltip": "复制",
"enterUrlError": "请输入 URL",
"clickToChange": "点击更换图片" "enterUrlError": "请输入 URL 或文本",
"clickToChange": "点击更换图片",
"pasteHint": "支持 Ctrl+V 粘贴图片或 Base64 字符串",
"autoGenerateHint": "输入内容后将自动生成二维码",
"autoParseHint": "上传图片后将自动解析二维码"
} }
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import QrCodePage from '../index';
// Mock 子组件
vi.mock('@/components/QrCodePreview', () => ({
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
}));
vi.mock('@/components/ImageUploader', () => ({
default: () => <div data-testid="image-uploader">ImageUploader</div>,
}));
// Mock QRious
vi.mock('qrious', () => ({
default: vi.fn().mockImplementation(() => ({
toDataURL: () => 'data:image/png;base64,mock',
})),
}));
// Mock useSnackbar
vi.mock('@/components/GlobalSnackbar', () => ({
useSnackbar: () => ({
showMessage: vi.fn(),
}),
}));
describe('QrCodePage', () => {
it('应该默认渲染生成模式', () => {
render(<QrCodePage />);
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
});
it('应该渲染模式切换按钮', () => {
render(<QrCodePage />);
expect(screen.getByText('qrCode:urlToQr')).toBeInTheDocument();
expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
});
it('应该渲染页面标题', () => {
render(<QrCodePage />);
expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument();
expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument();
});
it('切换到解析模式应该渲染 ImageUploader', () => {
render(<QrCodePage />);
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
});
it('切换回生成模式应该渲染 QrCodePreview', () => {
render(<QrCodePage />);
// 先切换到解析模式
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
// 再切换回生成模式
fireEvent.click(screen.getByText('qrCode:urlToQr'));
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
});
it('应该渲染输入区域', () => {
render(<QrCodePage />);
expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument();
});
it('应该渲染双栏布局容器', () => {
const { container } = render(<QrCodePage />);
const gridContainer = container.querySelector('.MuiGrid-container');
expect(gridContainer).toBeInTheDocument();
});
});
+95 -36
View File
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { Box, Container, Grid, useMediaQuery, useTheme } from '@mui/material'; import { Box, Container, Grid, useMediaQuery, useTheme } from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode'; import QrCodeIcon from '@mui/icons-material/QrCode';
import QRious from 'qrious'; import QRious from 'qrious';
@@ -12,6 +12,7 @@ import { useSnackbar } from '@/components/GlobalSnackbar';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useContextMenuData } from '@/utils/useContextMenuData'; import { useContextMenuData } from '@/utils/useContextMenuData';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useDebounce } from '@/utils/useDebounce';
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from './types'; import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from './types';
export default function Index() { export default function Index() {
@@ -44,7 +45,10 @@ export default function Index() {
// 生成二维码 // 生成二维码
const generateQrCode = useCallback( const generateQrCode = useCallback(
async (text: string) => { async (text: string) => {
if (!text) return; if (!text) {
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: '' }));
return;
}
try { try {
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' })); setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
@@ -63,10 +67,13 @@ export default function Index() {
}); });
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: qr.toDataURL() })); setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: qr.toDataURL() }));
showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) { } catch (error) {
console.error('生成二维码失败:', error); console.error('生成二维码失败:', error);
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); setGeneratorState((prev) => ({
...prev,
inputError: t('qrCode:generateError'),
}));
showMessage(t('qrCode:generateError'), { severity: 'error', autoHideDuration: 3000 });
} finally { } finally {
setGeneratorState((prev) => ({ ...prev, generating: false })); setGeneratorState((prev) => ({ ...prev, generating: false }));
} }
@@ -74,15 +81,25 @@ export default function Index() {
[t, showMessage], [t, showMessage],
); );
// 使用 useRef 存储 generateQrCode 的最新引用,避免无限循环
const generateQrCodeRef = useRef(generateQrCode);
generateQrCodeRef.current = generateQrCode;
// 防抖处理输入文本(200ms
const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200);
// 当防抖后的文本变化时,自动生成二维码
useEffect(() => {
if (debouncedTextToEncode && mode === 'generate') {
generateQrCodeRef.current(debouncedTextToEncode);
}
}, [debouncedTextToEncode, mode]);
// 处理右键菜单数据 // 处理右键菜单数据
const handleContextMenuData = useCallback( const handleContextMenuData = useCallback((payload: string) => {
(payload: string) => {
setMode('generate'); setMode('generate');
setGeneratorState((prev) => ({ ...prev, textToEncode: payload })); setGeneratorState((prev) => ({ ...prev, textToEncode: payload }));
generateQrCode(payload); }, []);
},
[generateQrCode],
);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
@@ -100,12 +117,13 @@ export default function Index() {
} else { } else {
const errorMsg = result.error || t('qrCode:noQrDetected'); const errorMsg = result.error || t('qrCode:noQrDetected');
setParserState((prev) => ({ ...prev, parseError: errorMsg })); setParserState((prev) => ({ ...prev, parseError: errorMsg }));
showMessage(errorMsg, { severity: 'error', autoHideDuration: 1000 }); showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
} }
} catch (error) { } catch (error) {
console.error('解析二维码失败:', error); console.error('解析二维码失败:', error);
setParserState((prev) => ({ ...prev, parseError: t('qrCode:parseError') })); const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); setParserState((prev) => ({ ...prev, parseError: errorMsg }));
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
} finally { } finally {
setParserState((prev) => ({ ...prev, parsing: false })); setParserState((prev) => ({ ...prev, parsing: false }));
} }
@@ -121,7 +139,7 @@ export default function Index() {
link.href = generatorState.qrCodeDataUrl; link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png'; link.download = 'qrcode.png';
link.click(); link.click();
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 300 }); showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
}, [generatorState.qrCodeDataUrl, showMessage, t]); }, [generatorState.qrCodeDataUrl, showMessage, t]);
// 复制二维码 // 复制二维码
@@ -141,10 +159,26 @@ export default function Index() {
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 }); showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) { } catch (error) {
console.error('复制二维码失败:', error); console.error('复制二维码失败:', error);
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
} }
}, [generatorState.qrCodeDataUrl, showMessage, t]); }, [generatorState.qrCodeDataUrl, showMessage, t]);
// 处理文件选择
const handleFileChange = useCallback(
(file: File) => {
setParserState((prev) => ({
...prev,
selectedFile: file,
previewUrl: URL.createObjectURL(file),
decodedResult: '',
parseError: '',
}));
// 自动解析
parseQrCode(file);
},
[parseQrCode],
);
// 清除文件 // 清除文件
const handleClearFile = useCallback(() => { const handleClearFile = useCallback(() => {
if (parserState.previewUrl) { if (parserState.previewUrl) {
@@ -159,6 +193,51 @@ export default function Index() {
})); }));
}, [parserState.previewUrl]); }, [parserState.previewUrl]);
// 全局粘贴事件监听(解析模式下)
useEffect(() => {
if (mode !== 'parse') return;
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
// 检查是否有图片
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
}
return;
}
}
// 检查是否有 Base64 字符串
const text = e.clipboardData?.getData('text/plain');
if (text && text.startsWith('data:image/')) {
e.preventDefault();
try {
// 将 Base64 转换为 File
const response = await fetch(text);
const blob = await response.blob();
const file = new File([blob], 'pasted-image.png', { type: blob.type });
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理 Base64 图片失败:', error);
showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 });
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [mode, handleFileChange, showMessage, t]);
// 模式选项 // 模式选项
const modeOptions = [ const modeOptions = [
{ value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') }, { value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') },
@@ -178,18 +257,6 @@ export default function Index() {
showClear showClear
allowCopy allowCopy
externalError={generatorState.inputError} externalError={generatorState.inputError}
actions={[
{
key: 'generate',
label: generatorState.generating
? t('qrCode:generating')
: t('qrCode:generateButton'),
type: 'primary',
position: 'bottom',
disabled: (value) => !value || generatorState.generating,
onClick: (value) => generateQrCode(value),
},
]}
showMessage={showMessage} showMessage={showMessage}
/> />
); );
@@ -198,15 +265,7 @@ export default function Index() {
return ( return (
<ImageUploader <ImageUploader
selectedFile={parserState.selectedFile} selectedFile={parserState.selectedFile}
onFileChange={(file) => { onFileChange={handleFileChange}
setParserState((prev) => ({
...prev,
selectedFile: file,
previewUrl: URL.createObjectURL(file),
decodedResult: '',
parseError: '',
}));
}}
onClearFile={handleClearFile} onClearFile={handleClearFile}
previewUrl={parserState.previewUrl} previewUrl={parserState.previewUrl}
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))} onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
+24
View File
@@ -0,0 +1,24 @@
import { useState, useEffect } from 'react';
/**
* useDebounce Hook - 防抖值
*
* @param value - 需要防抖的值
* @param delay - 延迟时间(毫秒)
* @returns 防抖后的值
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}