diff --git a/public/_locales/zh/messages.json b/public/_locales/zh/messages.json index b8b3a33..bd41e3e 100644 --- a/public/_locales/zh/messages.json +++ b/public/_locales/zh/messages.json @@ -576,6 +576,50 @@ "message": "点击更换图片", "description": "Translation key: qrCode_clickToChange" }, + "qrCode_generateButton": { + "message": "生成二维码", + "description": "Translation key: qrCode_generateButton" + }, + "qrCode_editButton": { + "message": "编辑", + "description": "Translation key: qrCode_editButton" + }, + "qrCode_textPreviewLabel": { + "message": "原始文本", + "description": "Translation key: qrCode_textPreviewLabel" + }, + "qrCode_generateFirstHint": { + "message": "输入文本后点击「生成二维码」按钮", + "description": "Translation key: qrCode_generateFirstHint" + }, + "qrCode_inputRequired": { + "message": "请输入内容", + "description": "Translation key: qrCode_inputRequired" + }, + "qrCode_generateError": { + "message": "生成二维码失败,请重试", + "description": "Translation key: qrCode_generateError" + }, + "qrCode_uploadedImage": { + "message": "已上传图片", + "description": "Translation key: qrCode_uploadedImage" + }, + "qrCode_reuploadButton": { + "message": "重新上传", + "description": "Translation key: qrCode_reuploadButton" + }, + "qrCode_parsing": { + "message": "解析中...", + "description": "Translation key: qrCode_parsing" + }, + "qrCode_resultPlaceholder": { + "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/components/QrCodePreview.tsx b/src/components/QrCodePreview.tsx index 35c43cb..f96a8a0 100644 --- a/src/components/QrCodePreview.tsx +++ b/src/components/QrCodePreview.tsx @@ -12,12 +12,15 @@ interface QrCodePreviewProps extends React.HTMLAttributes { onDownload: () => void; /** 复制回调 */ onCopy: () => void; + /** 自定义占位文本,用于空状态提示 */ + placeholderText?: string; } const QrCodePreview = ({ qrCodeDataUrl, onDownload, onCopy, + placeholderText, className, ...props }: QrCodePreviewProps) => { @@ -33,7 +36,9 @@ const QrCodePreview = ({ )} {...props} > -

{t('qrCode:qrCodeWillShow')}

+

+ {placeholderText || t('qrCode:qrCodeWillShow')} +

); } @@ -41,34 +46,34 @@ const QrCodePreview = ({ return (
{/* - 2. 二维码容器适配: + 二维码容器适配: 在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。 - 通过裹一层 bg-white 和 p-3,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。 + 通过裹一层 bg-white 和 p-2,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。 */} -
+
QR Code Preview
-
- -
diff --git a/src/pages/QrCode/__tests__/index.test.tsx b/src/pages/QrCode/__tests__/index.test.tsx index 5c04ded..eca7ae1 100644 --- a/src/pages/QrCode/__tests__/index.test.tsx +++ b/src/pages/QrCode/__tests__/index.test.tsx @@ -7,6 +7,8 @@ vi.mock('lucide-react', async (importOriginal) => { return { ...actual, QrCode: () =>
Icon
, + Pencil: () =>
Icon
, + Loader2: () =>
Icon
, }; }); @@ -32,20 +34,29 @@ vi.mock('@/components/ImageUploader', () => ({ default: () =>
ImageUploader
, })); -vi.mock('qrious', () => ({ - default: vi.fn().mockImplementation(() => ({ - toDataURL: () => 'data:image/png;base64,mock', - })), -})); +vi.mock('qrious', () => { + return { + default: class QRious { + constructor() { + return { + toDataURL: () => 'data:image/png;base64,mock', + }; + } + }, + }; +}); describe('QrCodePage', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('应该默认渲染生成模式', () => { + it('应该默认渲染生成模式的输入态', () => { render(); - expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); + expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument(); + expect(screen.getByText('生成二维码')).toBeInTheDocument(); + // 输入态应该隐藏二维码预览 + expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument(); }); it('应该渲染模式切换按钮', () => { @@ -58,27 +69,123 @@ describe('QrCodePage', () => { render(); fireEvent.click(screen.getByText('二维码转文本')); expect(screen.getByTestId('image-uploader')).toBeInTheDocument(); - expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument(); }); - it('切换回生成模式应该渲染 QrCodePreview', () => { + it('切换回生成模式应该渲染输入态', () => { render(); // 先切换到解析模式 fireEvent.click(screen.getByText('二维码转文本')); - expect(screen.getByTestId('image-uploader')).toBeInTheDocument(); // 再切换回生成模式 fireEvent.click(screen.getByText('文本转二维码')); - expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); + expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument(); + expect(screen.getByText('生成二维码')).toBeInTheDocument(); }); - it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => { + it('应该渲染输入区域的系统标签', () => { render(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument(); }); - it('应该渲染双翼响应式卡片网格布局', () => { - const { container } = render(); - const gridContainer = container.querySelector('.grid'); - expect(gridContainer).toBeInTheDocument(); + it('输入态应该显示生成按钮', () => { + render(); + const generateButton = screen.getByText('生成二维码'); + expect(generateButton).toBeInTheDocument(); + expect(generateButton).toBeDisabled(); // 空输入时按钮应该禁用 + }); + + it('有输入内容时生成按钮应该可用', () => { + render(); + const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码'); + fireEvent.change(textarea, { target: { value: 'https://example.com' } }); + + const generateButton = screen.getByText('生成二维码'); + expect(generateButton).not.toBeDisabled(); + }); + + it('点击生成按钮应该切换到预览态', () => { + render(); + + // 输入文本 + const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码'); + fireEvent.change(textarea, { target: { value: 'https://example.com' } }); + + // 点击生成按钮 + const generateButton = screen.getByText('生成二维码'); + fireEvent.click(generateButton); + + // 验证切换到预览态 + expect(screen.getByText('原始文本')).toBeInTheDocument(); + expect(screen.getByText('编辑')).toBeInTheDocument(); + expect(screen.queryByText('生成二维码')).not.toBeInTheDocument(); + // 预览态应该显示二维码 + expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); + }); + + it('预览态应该显示截断的文本', () => { + render(); + + // 输入长文本 + const longText = + 'https://example.com/very/long/path/that/should/be/truncated/in/the/preview/because/it/exceeds/the/maximum/length'; + const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码'); + fireEvent.change(textarea, { target: { value: longText } }); + + // 点击生成按钮 + fireEvent.click(screen.getByText('生成二维码')); + + // 验证显示截断的文本(80字符 + "...") + const truncatedText = longText.slice(0, 80) + '...'; + expect(screen.getByText(truncatedText)).toBeInTheDocument(); + }); + + it('预览态应该显示完整的短文本', () => { + render(); + + // 输入短文本 + const shortText = 'https://example.com'; + const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码'); + fireEvent.change(textarea, { target: { value: shortText } }); + + // 点击生成按钮 + fireEvent.click(screen.getByText('生成二维码')); + + // 验证显示完整文本 + expect(screen.getByText(shortText)).toBeInTheDocument(); + }); + + it('点击编辑按钮应该返回输入态', () => { + render(); + + // 输入文本并生成 + const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码'); + fireEvent.change(textarea, { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByText('生成二维码')); + + // 点击编辑按钮 + fireEvent.click(screen.getByText('编辑')); + + // 验证返回输入态 + expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument(); + expect(screen.getByText('生成二维码')).toBeInTheDocument(); + expect(screen.queryByText('编辑')).not.toBeInTheDocument(); + // 输入态应该隐藏二维码预览 + expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument(); + }); + + it('返回编辑态应该保留上次输入的内容', () => { + render(); + + // 输入文本并生成 + const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码'); + fireEvent.change(textarea, { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByText('生成二维码')); + + // 点击编辑按钮 + fireEvent.click(screen.getByText('编辑')); + + // 验证输入框保留了上次的内容 + const textareaAfterEdit = + screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码'); + expect(textareaAfterEdit).toHaveValue('https://example.com'); }); }); diff --git a/src/pages/QrCode/components/GeneratePanel.tsx b/src/pages/QrCode/components/GeneratePanel.tsx index 22cf88c..b67fb39 100644 --- a/src/pages/QrCode/components/GeneratePanel.tsx +++ b/src/pages/QrCode/components/GeneratePanel.tsx @@ -1,51 +1,114 @@ +import { Loader2, Pencil, QrCode } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; import QrCodePreview from '@/components/QrCodePreview'; import { useI18n } from '@/utils/chromeI18n'; import { useQrCodeContext } from '../contexts/QrCodeContext'; +import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; +/** 文本预览的最大显示字符数 */ +const TEXT_PREVIEW_MAX_LENGTH = 80; + export default function GeneratePanel() { const { t } = useI18n('qrCode'); - const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext(); + const { + generatorState, + setTextToEncode, + confirmGenerate, + backToEdit, + downloadQrCode, + copyQrCode, + } = useQrCodeContext(); - return ( -
-
-
- + const isInputStep = generatorState.step === 'input'; + const hasText = generatorState.textToEncode.trim().length > 0; + + /** 截断文本用于预览显示 */ + const getTruncatedText = (text: string) => { + if (text.length <= TEXT_PREVIEW_MAX_LENGTH) return text; + return text.slice(0, TEXT_PREVIEW_MAX_LENGTH) + '...'; + }; + + // 输入态:只显示输入框和生成按钮 + if (isInputStep) { + return ( +
+
+
+ -
setTextToEncode('')} /> + +
+ ); + } -
- + // 预览态:显示文本预览 + 二维码(紧凑布局,适配popup窗口) + return ( +
+ {/* 文本预览区域(固定高度,单行显示) */} +
+
+ + +
+
+ {getTruncatedText(generatorState.savedText)} +
+ + {/* 二维码预览区域(自适应剩余空间) */} +
); } diff --git a/src/pages/QrCode/components/ParsePanel.tsx b/src/pages/QrCode/components/ParsePanel.tsx index a0a2aa6..aed49c8 100644 --- a/src/pages/QrCode/components/ParsePanel.tsx +++ b/src/pages/QrCode/components/ParsePanel.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect } from 'react'; +import { RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; import TextInputArea from '@/components/TextInputArea'; import ImageUploader from '@/components/ImageUploader'; import { useI18n } from '@/utils/chromeI18n'; import { useQrCodeContext } from '../contexts/QrCodeContext'; +import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; @@ -11,6 +13,8 @@ export default function ParsePanel() { const { t } = useI18n('qrCode'); const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext(); + const hasFile = parserState.selectedFile !== null; + // 全局粘贴事件监听 const handlePaste = useCallback( async (e: ClipboardEvent) => { @@ -54,9 +58,10 @@ export default function ParsePanel() { }; }, [handlePaste]); - return ( -
-
+ // 未上传图片:只显示上传区域 + if (!hasFile) { + return ( +
setParserState((prev) => ({ ...prev, dragging }))} />
+ ); + } + // 已上传图片:显示图片预览 + 解析结果 + return ( +
+ {/* 图片预览区域(小图预览) */} +
+
+ + +
+
+ Uploaded QR Code +
+

{parserState.selectedFile?.name}

+ {parserState.parsing && ( +

{t('qrCode:parsing')}

+ )} +
+
+
+ + {/* 解析结果区域 */}
-
+
-
- -
+
diff --git a/src/pages/QrCode/contexts/QrCodeContext.ts b/src/pages/QrCode/contexts/QrCodeContext.ts index fe6d02f..fbe4a30 100644 --- a/src/pages/QrCode/contexts/QrCodeContext.ts +++ b/src/pages/QrCode/contexts/QrCodeContext.ts @@ -8,6 +8,10 @@ export interface QrCodeContextValue { generatorState: QrCodeGeneratorState; setTextToEncode: (text: string) => void; + /** 显式触发生成二维码并切换到预览态 */ + confirmGenerate: () => void; + /** 返回编辑态,保留上次输入内容 */ + backToEdit: () => void; downloadQrCode: () => void; copyQrCode: () => Promise; diff --git a/src/pages/QrCode/hooks/useQrCode.ts b/src/pages/QrCode/hooks/useQrCode.ts index bedf5c3..a88b019 100644 --- a/src/pages/QrCode/hooks/useQrCode.ts +++ b/src/pages/QrCode/hooks/useQrCode.ts @@ -1,22 +1,93 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import QRious from 'qrious'; import { toast } from 'sonner'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { useContextMenuData } from '@/utils/useContextMenuData'; import { useI18n } from '@/utils/chromeI18n'; -import { useDebounce } from '@/utils/useDebounce'; import type { QrCodeContextValue } from '../contexts/QrCodeContext'; import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types'; +/** 防抖延迟时间(毫秒) */ +const DEBOUNCE_DELAY = 500; + +/** 检测文本是否为URL格式 */ +function isUrl(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + + // 检查是否以 http:// 或 https:// 开头 + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return true; + } + + // 检查是否为域名格式(包含.且不以特殊字符开头) + const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+([/:].*)?$/; + return domainPattern.test(trimmed); +} + +/** 检测URL是否为图片格式 */ +function isImageUrl(url: string): boolean { + const trimmedUrl = url.trim().toLowerCase(); + + // 检查是否为 data:image 格式 + if (trimmedUrl.startsWith('data:image/')) { + return true; + } + + // 检查是否包含图片扩展名 + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico']; + if (imageExtensions.some((ext) => trimmedUrl.includes(ext))) { + return true; + } + + // 检查URL路径中是否包含图片相关关键词 + const imageKeywords = ['/image/', '/img/', '/photo/', '/pic/', '/upload/']; + if (imageKeywords.some((keyword) => trimmedUrl.includes(keyword))) { + return true; + } + + return false; +} + +/** 生成二维码的核心逻辑 */ +function generateQrCodeDataUrl(text: string): string { + const trimmedText = text.trim(); + if (!trimmedText) return ''; + + try { + let url = trimmedText; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + + const isDark = document.documentElement.classList.contains('dark'); + + const qr = new QRious({ + value: url, + size: 260, + level: 'H', + foreground: isDark ? '#f3f4f6' : '#0f172a', + background: isDark ? 'transparent' : '#ffffff', + }); + + return qr.toDataURL(); + } catch (error) { + console.error('QR code generation failed:', error); + return ''; + } +} + export function useQrCode(): QrCodeContextValue { const { t } = useI18n('qrCode'); const [mode, setMode] = useState('generate'); - const [generatorState, setGeneratorState] = useState< - Omit - >({ + const [generatorState, setGeneratorState] = useState({ + step: 'input', textToEncode: '', + savedText: '', + qrCodeDataUrl: '', + generating: false, inputError: '', }); @@ -29,52 +100,192 @@ export function useQrCode(): QrCodeContextValue { dragging: false, }); - const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200); + const debounceTimerRef = useRef | null>(null); - const qrCodeDataUrl = useMemo(() => { - const text = debouncedTextToEncode.trim(); - if (!text) return ''; + // 清除防抖定时器 + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); - try { - let url = text; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; + /** 自动检测URL并生成二维码 */ + const autoGenerateIfUrl = useCallback( + (text: string) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); } - const isDark = document.documentElement.classList.contains('dark'); + if (!isUrl(text)) { + return; + } - const qr = new QRious({ - value: url, - size: 260, - level: 'H', - foreground: isDark ? '#f3f4f6' : '#0f172a', - background: isDark ? 'transparent' : '#ffffff', - }); + debounceTimerRef.current = setTimeout(() => { + setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' })); - return qr.toDataURL(); - } catch (error) { - console.error('QR code generation sync task failed:', error); - return ''; - } - }, [debouncedTextToEncode]); + const qrCodeDataUrl = generateQrCodeDataUrl(text); - const fullGeneratorState = useMemo( - () => ({ - ...generatorState, - qrCodeDataUrl, - generating: false, - }), - [generatorState, qrCodeDataUrl], + if (qrCodeDataUrl) { + setGeneratorState((prev) => ({ + ...prev, + step: 'preview', + savedText: text.trim(), + qrCodeDataUrl, + generating: false, + })); + } else { + setGeneratorState((prev) => ({ + ...prev, + generating: false, + inputError: t('qrCode:generateError'), + })); + } + }, DEBOUNCE_DELAY); + }, + [t], ); - const setTextToEncode = useCallback((text: string) => { - setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' })); + const setTextToEncode = useCallback( + (text: string) => { + setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' })); + autoGenerateIfUrl(text); + }, + [autoGenerateIfUrl], + ); + + /** 手动触发生成二维码(用于非URL文本) */ + const confirmGenerate = useCallback(() => { + const text = generatorState.textToEncode.trim(); + + if (!text) { + setGeneratorState((prev) => ({ ...prev, inputError: t('qrCode:inputRequired') })); + toast.error(t('qrCode:inputRequired')); + return; + } + + setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' })); + + const qrCodeDataUrl = generateQrCodeDataUrl(text); + + if (!qrCodeDataUrl) { + setGeneratorState((prev) => ({ + ...prev, + generating: false, + inputError: t('qrCode:generateError'), + })); + toast.error(t('qrCode:generateError')); + return; + } + + setGeneratorState((prev) => ({ + ...prev, + step: 'preview', + savedText: text, + qrCodeDataUrl, + generating: false, + })); + }, [generatorState.textToEncode, t]); + + /** 返回编辑态,保留上次输入内容 */ + const backToEdit = useCallback(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + setGeneratorState((prev) => ({ + ...prev, + step: 'input', + textToEncode: prev.savedText, + qrCodeDataUrl: '', + inputError: '', + })); }, []); - const handleContextMenuData = useCallback((payload: string) => { - setMode('generate'); - setGeneratorState((prev) => ({ ...prev, textToEncode: payload, inputError: '' })); - }, []); + /** 从图片URL解析二维码 */ + const parseQrCodeFromUrl = useCallback( + async (imageUrl: string) => { + try { + setParserState((prev) => ({ + ...prev, + parsing: true, + parseError: '', + decodedResult: '', + previewUrl: imageUrl, + selectedFile: null, + })); + + // 从URL获取图片并转换为File对象 + const response = await fetch(imageUrl); + const blob = await response.blob(); + const file = new File([blob], 'qrcode-image.png', { type: blob.type }); + + 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 }); @@ -107,20 +318,20 @@ export function useQrCode(): QrCodeContextValue { ); const downloadQrCode = useCallback(() => { - if (!qrCodeDataUrl) return; + if (!generatorState.qrCodeDataUrl) return; const link = document.createElement('a'); - link.href = qrCodeDataUrl; + link.href = generatorState.qrCodeDataUrl; link.download = 'qrcode.png'; link.click(); toast.success(t('qrCode:qrCodeDownloadSuccess')); - }, [qrCodeDataUrl, t]); + }, [generatorState.qrCodeDataUrl, t]); const copyQrCode = useCallback(async () => { - if (!qrCodeDataUrl) return; + if (!generatorState.qrCodeDataUrl) return; try { - const response = await fetch(qrCodeDataUrl); + const response = await fetch(generatorState.qrCodeDataUrl); const blob = await response.blob(); await navigator.clipboard.write([ @@ -134,7 +345,7 @@ export function useQrCode(): QrCodeContextValue { console.error('复制二维码失败:', error); toast.error(t('qrCode:copyError')); } - }, [qrCodeDataUrl, t]); + }, [generatorState.qrCodeDataUrl, t]); const handleFileChange = useCallback( (file: File) => { @@ -178,8 +389,10 @@ export function useQrCode(): QrCodeContextValue { return { mode, setMode, - generatorState: fullGeneratorState, + generatorState, setTextToEncode, + confirmGenerate, + backToEdit, parseQrCode, downloadQrCode, copyQrCode, diff --git a/src/pages/QrCode/types.ts b/src/pages/QrCode/types.ts index b2ab4d9..860fb16 100644 --- a/src/pages/QrCode/types.ts +++ b/src/pages/QrCode/types.ts @@ -5,16 +5,23 @@ /** 二维码功能核心主路由模式 */ export type QrCodeMode = 'generate' | 'parse'; +/** 生成器的操作步骤 */ +export type GeneratorStep = 'input' | 'preview'; + /** * 二维码生成器的状态 - * 💡 架构优化:保留与全局 Context 骨架契合的形态, - * 外部依然可以流畅读取这些状态,但在新架构下运行效率和稳定性大幅提升! + * 💡 架构优化:采用两步操作模式(输入态 → 预览态), + * 用户显式点击生成按钮后才生成二维码,体验更清晰! */ export interface QrCodeGeneratorState { + /** 当前操作步骤:输入态或预览态 */ + step: GeneratorStep; /** 受控的输入源文本(支持 URL 或任意文本快照) */ textToEncode: string; - /** 由防抖源文本流在单次渲染内存中同步派生出的二维码 Base64 Data URL */ + /** 保存的文本快照,用于预览态展示和编辑态回填 */ + savedText: string; + /** 由用户显式触发生成的二维码 Base64 Data URL */ qrCodeDataUrl: string; - /** 是否正在生成(流式架构下已默认为恒定 false 的非阻塞快照,保留作为 UI 骨架兼容) */ + /** 是否正在生成 */ generating: boolean; /** 输入文本校验或底层画布崩溃的错误提示信息 */ inputError: string; diff --git a/src/utils/__tests__/contextMenu.test.ts b/src/utils/__tests__/contextMenu.test.ts index 49c09f9..071619b 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..f394619 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/'], });