diff --git a/entrypoints/background.ts b/entrypoints/background.ts
index c835720..daab621 100644
--- a/entrypoints/background.ts
+++ b/entrypoints/background.ts
@@ -40,8 +40,9 @@ export default defineBackground(() => {
try {
await browser.action.openPopup();
} catch (err) {
- console.error('[Context Menu] 打开 popup 失败:', err);
- // 打开失败时清除残留数据,避免下次打开 popup 时误触发
+ // openPopup 在无活动窗口时会失败(如窗口失焦、特殊页面等)
+ // 数据已保存到 storage,用户手动打开 popup 仍可正常使用
+ console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标:', err);
await chrome.storage.local.remove('contextMenu/pendingData');
}
});
diff --git a/i18n/locales/en/qrCode.json b/i18n/locales/en/qrCode.json
index a1098e1..79e6be7 100644
--- a/i18n/locales/en/qrCode.json
+++ b/i18n/locales/en/qrCode.json
@@ -1,31 +1,48 @@
{
"pageTitle": "QR Code Tools",
"pageSubtitle": "Generate and parse QR codes",
+
+ "generateMode": "Generate QR Code",
+ "parseMode": "Parse QR Code",
+
"urlToQr": "URL to QR Code",
"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",
"generating": "Generating...",
+
"qrCodeWillShow": "QR code will be shown here",
"downloadButton": "Download QR Code",
"copyQrButton": "Copy QR Code Image",
+
"qrCodeSuccess": "QR code generated successfully",
"qrCodeDownloadSuccess": "QR code downloaded successfully",
"qrCodeCopySuccess": "QR code copied to clipboard",
+
"selectImage": "Please select a QR code image",
"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",
- "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",
"imageCleared": "Image cleared",
"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",
"parsing": "Parsing...",
"resultLabel": "Parsing Result",
"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"
}
diff --git a/i18n/locales/zh/qrCode.json b/i18n/locales/zh/qrCode.json
index f90698a..65f86f9 100644
--- a/i18n/locales/zh/qrCode.json
+++ b/i18n/locales/zh/qrCode.json
@@ -1,31 +1,48 @@
{
"pageTitle": "二维码工具",
"pageSubtitle": "生成和解析二维码",
+
+ "generateMode": "生成二维码",
+ "parseMode": "解析二维码",
+
"urlToQr": "URL 转二维码",
"qrToUrl": "二维码转 URL",
- "urlInputLabel": "输入 URL",
- "urlInputPlaceholder": "https://example.com",
+
+ "urlInputLabel": "输入 URL 或文本",
+ "urlInputPlaceholder": "请输入 URL 或文本内容,将自动生成二维码",
"generateButton": "生成二维码",
"generating": "生成中...",
+
"qrCodeWillShow": "二维码将显示在这里",
"downloadButton": "下载二维码",
"copyQrButton": "复制二维码",
+
"qrCodeSuccess": "二维码生成成功",
"qrCodeDownloadSuccess": "二维码下载成功",
"qrCodeCopySuccess": "二维码已复制到剪贴板",
+
"selectImage": "请选择二维码图片",
"parseSuccess": "二维码解析成功",
- "noQrDetected": "未检测到二维码",
+ "noQrDetected": "未检测到二维码,请确保图片清晰且包含二维码",
"parseError": "解析二维码失败,请重试",
- "imagePasted": "图片粘贴成功",
+ "generateError": "生成二维码失败,请检查输入内容",
+ "copyError": "复制失败,请重试",
+
+ "imagePasted": "图片粘贴成功,正在解析...",
"imagePasteError": "粘贴图片失败,请重试",
"imageCleared": "图片已清除",
"clickToUpload": "点击、拖拽或粘贴上传二维码图片",
- "supportFormats": "支持 PNG、JPG、WEBP 格式",
+ "supportFormats": "支持 PNG、JPG、WEBP、Base64 格式",
+
"parseButton": "解析二维码",
"parsing": "解析中...",
"resultLabel": "解析结果",
"copyTooltip": "复制",
- "enterUrlError": "请输入 URL",
- "clickToChange": "点击更换图片"
+
+ "enterUrlError": "请输入 URL 或文本",
+ "clickToChange": "点击更换图片",
+
+ "pasteHint": "支持 Ctrl+V 粘贴图片或 Base64 字符串",
+ "autoGenerateHint": "输入内容后将自动生成二维码",
+ "autoParseHint": "上传图片后将自动解析二维码"
}
diff --git a/pages/QrCode/__tests__/index.test.tsx b/pages/QrCode/__tests__/index.test.tsx
new file mode 100644
index 0000000..de06001
--- /dev/null
+++ b/pages/QrCode/__tests__/index.test.tsx
@@ -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: () =>
QrCodePreview
,
+}));
+
+vi.mock('@/components/ImageUploader', () => ({
+ default: () => ImageUploader
,
+}));
+
+// 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();
+ expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
+ });
+
+ it('应该渲染模式切换按钮', () => {
+ render();
+ expect(screen.getByText('qrCode:urlToQr')).toBeInTheDocument();
+ expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
+ });
+
+ it('应该渲染页面标题', () => {
+ render();
+ expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument();
+ expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument();
+ });
+
+ it('切换到解析模式应该渲染 ImageUploader', () => {
+ render();
+ fireEvent.click(screen.getByText('qrCode:qrToUrl'));
+ expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
+ expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
+ });
+
+ it('切换回生成模式应该渲染 QrCodePreview', () => {
+ render();
+ // 先切换到解析模式
+ 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();
+ expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument();
+ });
+
+ it('应该渲染双栏布局容器', () => {
+ const { container } = render();
+ const gridContainer = container.querySelector('.MuiGrid-container');
+ expect(gridContainer).toBeInTheDocument();
+ });
+});
diff --git a/pages/QrCode/index.tsx b/pages/QrCode/index.tsx
index 98e93fe..4f055e8 100644
--- a/pages/QrCode/index.tsx
+++ b/pages/QrCode/index.tsx
@@ -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 QrCodeIcon from '@mui/icons-material/QrCode';
import QRious from 'qrious';
@@ -12,6 +12,7 @@ import { useSnackbar } from '@/components/GlobalSnackbar';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useContextMenuData } from '@/utils/useContextMenuData';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
+import { useDebounce } from '@/utils/useDebounce';
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from './types';
export default function Index() {
@@ -44,7 +45,10 @@ export default function Index() {
// 生成二维码
const generateQrCode = useCallback(
async (text: string) => {
- if (!text) return;
+ if (!text) {
+ setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: '' }));
+ return;
+ }
try {
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
@@ -63,10 +67,13 @@ export default function Index() {
});
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: qr.toDataURL() }));
- showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (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 {
setGeneratorState((prev) => ({ ...prev, generating: false }));
}
@@ -74,15 +81,25 @@ export default function Index() {
[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(
- (payload: string) => {
- setMode('generate');
- setGeneratorState((prev) => ({ ...prev, textToEncode: payload }));
- generateQrCode(payload);
- },
- [generateQrCode],
- );
+ const handleContextMenuData = useCallback((payload: string) => {
+ setMode('generate');
+ setGeneratorState((prev) => ({ ...prev, textToEncode: payload }));
+ }, []);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
@@ -100,12 +117,13 @@ export default function Index() {
} else {
const errorMsg = result.error || t('qrCode:noQrDetected');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
- showMessage(errorMsg, { severity: 'error', autoHideDuration: 1000 });
+ showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
}
} catch (error) {
console.error('解析二维码失败:', error);
- setParserState((prev) => ({ ...prev, parseError: t('qrCode:parseError') }));
- showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
+ const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
+ setParserState((prev) => ({ ...prev, parseError: errorMsg }));
+ showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
} finally {
setParserState((prev) => ({ ...prev, parsing: false }));
}
@@ -121,7 +139,7 @@ export default function Index() {
link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
- showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 300 });
+ showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
}, [generatorState.qrCodeDataUrl, showMessage, t]);
// 复制二维码
@@ -141,10 +159,26 @@ export default function Index() {
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
- showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
+ showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
}
}, [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(() => {
if (parserState.previewUrl) {
@@ -159,6 +193,51 @@ export default function Index() {
}));
}, [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 = [
{ value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') },
@@ -178,18 +257,6 @@ export default function Index() {
showClear
allowCopy
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}
/>
);
@@ -198,15 +265,7 @@ export default function Index() {
return (
{
- setParserState((prev) => ({
- ...prev,
- selectedFile: file,
- previewUrl: URL.createObjectURL(file),
- decodedResult: '',
- parseError: '',
- }));
- }}
+ onFileChange={handleFileChange}
onClearFile={handleClearFile}
previewUrl={parserState.previewUrl}
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
diff --git a/utils/useDebounce.ts b/utils/useDebounce.ts
new file mode 100644
index 0000000..e9a70ac
--- /dev/null
+++ b/utils/useDebounce.ts
@@ -0,0 +1,24 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * useDebounce Hook - 防抖值
+ *
+ * @param value - 需要防抖的值
+ * @param delay - 延迟时间(毫秒)
+ * @returns 防抖后的值
+ */
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}