diff --git a/public/_locales/zh/messages.json b/public/_locales/zh/messages.json index b8b3a33..0addc30 100644 --- a/public/_locales/zh/messages.json +++ b/public/_locales/zh/messages.json @@ -576,6 +576,30 @@ "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" + }, "rightClickRestorer_loading": { "message": "正在加载...", "description": "Translation key: rightClickRestorer_loading" diff --git a/src/components/QrCodePreview.tsx b/src/components/QrCodePreview.tsx index 35c43cb..8c3da70 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')} +

); } diff --git a/src/pages/QrCode/__tests__/index.test.tsx b/src/pages/QrCode/__tests__/index.test.tsx index 5c04ded..1f52b3c 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
, }; }); @@ -25,26 +27,39 @@ vi.mock('@/config/features', async (importOriginal) => { }); vi.mock('@/components/QrCodePreview', () => ({ - default: () =>
QrCodePreview
, + default: ({ placeholderText }: { placeholderText?: string }) => ( +
+ QrCodePreview + {placeholderText && {placeholderText}} +
+ ), })); 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.getByText('输入 URL 或文本')).toBeInTheDocument(); + expect(screen.getByText('生成二维码')).toBeInTheDocument(); expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); }); @@ -71,7 +86,7 @@ describe('QrCodePage', () => { expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); }); - it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => { + it('应该渲染输入区域的系统标签', () => { render(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument(); }); @@ -81,4 +96,85 @@ describe('QrCodePage', () => { 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(); + }); + + it('预览态应该显示保存的文本', () => { + render(); + + // 输入文本 + const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码'); + fireEvent.change(textarea, { target: { value: 'https://example.com' } }); + + // 点击生成按钮 + fireEvent.click(screen.getByText('生成二维码')); + + // 验证显示保存的文本 + expect(screen.getByText('https://example.com')).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(); + }); + + 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..76bfdca 100644 --- a/src/pages/QrCode/components/GeneratePanel.tsx +++ b/src/pages/QrCode/components/GeneratePanel.tsx @@ -1,49 +1,108 @@ +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'; export default function GeneratePanel() { const { t } = useI18n('qrCode'); - const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext(); + const { + generatorState, + setTextToEncode, + confirmGenerate, + backToEdit, + downloadQrCode, + copyQrCode, + } = useQrCodeContext(); + + const isInputStep = generatorState.step === 'input'; + const hasText = generatorState.textToEncode.trim().length > 0; return (
+ {/* 左侧:输入态显示输入框,预览态显示文本预览 */}
- +
+ + + {/* 预览态显示编辑按钮 */} + {!isInputStep && ( + + )} +
- setTextToEncode('')} - /> + {isInputStep ? ( + /* 输入态:可编辑的输入框 */ + setTextToEncode('')} + /> + ) : ( + /* 预览态:只读文本预览 */ +
+

+ {generatorState.savedText} +

+
+ )}
+ + {/* 输入态显示生成按钮 */} + {isInputStep && ( + + )}
+ {/* 右侧:二维码预览区域 */}
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..f608575 100644 --- a/src/pages/QrCode/hooks/useQrCode.ts +++ b/src/pages/QrCode/hooks/useQrCode.ts @@ -1,22 +1,51 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, 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'; +/** 生成二维码的核心逻辑 */ +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,51 +58,64 @@ export function useQrCode(): QrCodeContextValue { dragging: false, }); - const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200); - - const qrCodeDataUrl = useMemo(() => { - const text = debouncedTextToEncode.trim(); - if (!text) return ''; - - try { - let url = text; - 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 sync task failed:', error); - return ''; - } - }, [debouncedTextToEncode]); - - const fullGeneratorState = useMemo( - () => ({ - ...generatorState, - qrCodeDataUrl, - generating: false, - }), - [generatorState, qrCodeDataUrl], - ); - const setTextToEncode = useCallback((text: string) => { setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' })); }, []); + /** 显式触发生成二维码并切换到预览态 */ + 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(() => { + setGeneratorState((prev) => ({ + ...prev, + step: 'input', + textToEncode: prev.savedText, + qrCodeDataUrl: '', + inputError: '', + })); + }, []); + const handleContextMenuData = useCallback((payload: string) => { setMode('generate'); - setGeneratorState((prev) => ({ ...prev, textToEncode: payload, inputError: '' })); + setGeneratorState((prev) => ({ + ...prev, + step: 'input', + textToEncode: payload, + savedText: '', + qrCodeDataUrl: '', + inputError: '', + })); }, []); useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); @@ -107,20 +149,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 +176,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 +220,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;