refactor(qrcode): 优化文本转二维码为两步操作模式

- 将实时预览改为显式触发生成,用户体验更清晰
- 新增输入态(input)和预览态(preview)两种状态
- 输入态:显示输入框和生成按钮,右侧显示占位提示
- 预览态:显示只读文本预览和编辑按钮,右侧显示二维码
- 保存上次输入内容,返回编辑态时自动回填
- 添加空输入校验和错误提示
- 更新国际化文案
- 完善单元测试覆盖新功能
This commit is contained in:
雨霖铃
2026-05-30 20:23:05 +08:00
parent afd57bb5b2
commit 6dca673a5a
7 changed files with 320 additions and 81 deletions
+24
View File
@@ -576,6 +576,30 @@
"message": "点击更换图片", "message": "点击更换图片",
"description": "Translation key: qrCode_clickToChange" "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": { "rightClickRestorer_loading": {
"message": "正在加载...", "message": "正在加载...",
"description": "Translation key: rightClickRestorer_loading" "description": "Translation key: rightClickRestorer_loading"
+6 -1
View File
@@ -12,12 +12,15 @@ interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
onDownload: () => void; onDownload: () => void;
/** 复制回调 */ /** 复制回调 */
onCopy: () => void; onCopy: () => void;
/** 自定义占位文本,用于空状态提示 */
placeholderText?: string;
} }
const QrCodePreview = ({ const QrCodePreview = ({
qrCodeDataUrl, qrCodeDataUrl,
onDownload, onDownload,
onCopy, onCopy,
placeholderText,
className, className,
...props ...props
}: QrCodePreviewProps) => { }: QrCodePreviewProps) => {
@@ -33,7 +36,9 @@ const QrCodePreview = ({
)} )}
{...props} {...props}
> >
<p className="text-sm text-muted-foreground text-center">{t('qrCode:qrCodeWillShow')}</p> <p className="text-sm text-muted-foreground text-center">
{placeholderText || t('qrCode:qrCodeWillShow')}
</p>
</div> </div>
); );
} }
+104 -8
View File
@@ -7,6 +7,8 @@ vi.mock('lucide-react', async (importOriginal) => {
return { return {
...actual, ...actual,
QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>, QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>,
Pencil: () => <div data-testid="mock-lucide-pencil">Icon</div>,
Loader2: () => <div data-testid="mock-lucide-loader">Icon</div>,
}; };
}); });
@@ -25,26 +27,39 @@ vi.mock('@/config/features', async (importOriginal) => {
}); });
vi.mock('@/components/QrCodePreview', () => ({ vi.mock('@/components/QrCodePreview', () => ({
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>, default: ({ placeholderText }: { placeholderText?: string }) => (
<div data-testid="qr-code-preview">
QrCodePreview
{placeholderText && <span data-testid="placeholder-text">{placeholderText}</span>}
</div>
),
})); }));
vi.mock('@/components/ImageUploader', () => ({ vi.mock('@/components/ImageUploader', () => ({
default: () => <div data-testid="image-uploader">ImageUploader</div>, default: () => <div data-testid="image-uploader">ImageUploader</div>,
})); }));
vi.mock('qrious', () => ({ vi.mock('qrious', () => {
default: vi.fn().mockImplementation(() => ({ return {
toDataURL: () => 'data:image/png;base64,mock', default: class QRious {
})), constructor() {
})); return {
toDataURL: () => 'data:image/png;base64,mock',
};
}
},
};
});
describe('QrCodePage', () => { describe('QrCodePage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('应该默认渲染生成模式', () => { it('应该默认渲染生成模式的输入态', () => {
render(<QrCodePage />); render(<QrCodePage />);
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
expect(screen.getByText('生成二维码')).toBeInTheDocument();
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
}); });
@@ -71,7 +86,7 @@ describe('QrCodePage', () => {
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
}); });
it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => { it('应该渲染输入区域的系统标签', () => {
render(<QrCodePage />); render(<QrCodePage />);
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
}); });
@@ -81,4 +96,85 @@ describe('QrCodePage', () => {
const gridContainer = container.querySelector('.grid'); const gridContainer = container.querySelector('.grid');
expect(gridContainer).toBeInTheDocument(); expect(gridContainer).toBeInTheDocument();
}); });
it('输入态应该显示生成按钮', () => {
render(<QrCodePage />);
const generateButton = screen.getByText('生成二维码');
expect(generateButton).toBeInTheDocument();
expect(generateButton).toBeDisabled(); // 空输入时按钮应该禁用
});
it('有输入内容时生成按钮应该可用', () => {
render(<QrCodePage />);
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
const generateButton = screen.getByText('生成二维码');
expect(generateButton).not.toBeDisabled();
});
it('点击生成按钮应该切换到预览态', () => {
render(<QrCodePage />);
// 输入文本
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(<QrCodePage />);
// 输入文本
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(<QrCodePage />);
// 输入文本并生成
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(<QrCodePage />);
// 输入文本并生成
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');
});
}); });
+76 -17
View File
@@ -1,49 +1,108 @@
import { Loader2, Pencil, QrCode } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import QrCodePreview from '@/components/QrCodePreview'; import QrCodePreview from '@/components/QrCodePreview';
import { useI18n } from '@/utils/chromeI18n'; import { useI18n } from '@/utils/chromeI18n';
import { useQrCodeContext } from '../contexts/QrCodeContext'; import { useQrCodeContext } from '../contexts/QrCodeContext';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export default function GeneratePanel() { export default function GeneratePanel() {
const { t } = useI18n('qrCode'); 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 ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5">
{/* 左侧:输入态显示输入框,预览态显示文本预览 */}
<div <div
className={cn( className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4', 'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring', isInputStep && 'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
)} )}
> >
<div className="flex flex-col space-y-2.5 h-full"> <div className="flex flex-col space-y-2.5 h-full">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> <div className="flex items-center justify-between">
{t('qrCode:urlInputLabel')} <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
</Label> {isInputStep ? t('qrCode:urlInputLabel') : t('qrCode:textPreviewLabel')}
</Label>
{/* 预览态显示编辑按钮 */}
{!isInputStep && (
<Button variant="ghost" size="sm" onClick={backToEdit} className="h-7 px-2 text-xs">
<Pencil className="w-3 h-3 mr-1" />
{t('qrCode:editButton')}
</Button>
)}
</div>
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<TextInputArea {isInputStep ? (
value={generatorState.textToEncode} /* 输入态:可编辑的输入框 */
onChange={setTextToEncode} <TextInputArea
placeholder={t('qrCode:urlInputPlaceholder')} value={generatorState.textToEncode}
showCount={true} onChange={setTextToEncode}
showClear={true} placeholder={t('qrCode:urlInputPlaceholder')}
allowCopy={true} showCount={true}
minRows={6} showClear={true}
maxRows={12} allowCopy={false}
externalError={generatorState.inputError || undefined} minRows={6}
onClear={() => setTextToEncode('')} maxRows={12}
/> externalError={generatorState.inputError || undefined}
onClear={() => setTextToEncode('')}
/>
) : (
/* 预览态:只读文本预览 */
<div
className="h-full min-h-[150px] p-3 rounded-md border border-input bg-muted/50 overflow-auto"
title={generatorState.savedText}
>
<p className="text-sm text-foreground whitespace-pre-wrap break-all">
{generatorState.savedText}
</p>
</div>
)}
</div> </div>
{/* 输入态显示生成按钮 */}
{isInputStep && (
<Button
onClick={confirmGenerate}
disabled={!hasText || generatorState.generating}
className="w-full"
>
{generatorState.generating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('qrCode:generating')}
</>
) : (
<>
<QrCode className="w-4 h-4 mr-2" />
{t('qrCode:generateButton')}
</>
)}
</Button>
)}
</div> </div>
</div> </div>
{/* 右侧:二维码预览区域 */}
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<QrCodePreview <QrCodePreview
qrCodeDataUrl={generatorState.qrCodeDataUrl} qrCodeDataUrl={generatorState.qrCodeDataUrl}
onDownload={downloadQrCode} onDownload={downloadQrCode}
onCopy={copyQrCode} onCopy={copyQrCode}
placeholderText={isInputStep ? t('qrCode:generateFirstHint') : undefined}
/> />
</div> </div>
</div> </div>
@@ -8,6 +8,10 @@ export interface QrCodeContextValue {
generatorState: QrCodeGeneratorState; generatorState: QrCodeGeneratorState;
setTextToEncode: (text: string) => void; setTextToEncode: (text: string) => void;
/** 显式触发生成二维码并切换到预览态 */
confirmGenerate: () => void;
/** 返回编辑态,保留上次输入内容 */
backToEdit: () => void;
downloadQrCode: () => void; downloadQrCode: () => void;
copyQrCode: () => Promise<void>; copyQrCode: () => Promise<void>;
+95 -51
View File
@@ -1,22 +1,51 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useState } from 'react';
import QRious from 'qrious'; import QRious from 'qrious';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useContextMenuData } from '@/utils/useContextMenuData'; import { useContextMenuData } from '@/utils/useContextMenuData';
import { useI18n } from '@/utils/chromeI18n'; import { useI18n } from '@/utils/chromeI18n';
import { useDebounce } from '@/utils/useDebounce';
import type { QrCodeContextValue } from '../contexts/QrCodeContext'; import type { QrCodeContextValue } from '../contexts/QrCodeContext';
import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types'; 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 { export function useQrCode(): QrCodeContextValue {
const { t } = useI18n('qrCode'); const { t } = useI18n('qrCode');
const [mode, setMode] = useState<QrCodeMode>('generate'); const [mode, setMode] = useState<QrCodeMode>('generate');
const [generatorState, setGeneratorState] = useState< const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
Omit<QrCodeGeneratorState, 'generating' | 'qrCodeDataUrl'> step: 'input',
>({
textToEncode: '', textToEncode: '',
savedText: '',
qrCodeDataUrl: '',
generating: false,
inputError: '', inputError: '',
}); });
@@ -29,51 +58,64 @@ export function useQrCode(): QrCodeContextValue {
dragging: false, 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<QrCodeGeneratorState>(
() => ({
...generatorState,
qrCodeDataUrl,
generating: false,
}),
[generatorState, qrCodeDataUrl],
);
const setTextToEncode = useCallback((text: string) => { const setTextToEncode = useCallback((text: string) => {
setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' })); 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) => { const handleContextMenuData = useCallback((payload: string) => {
setMode('generate'); setMode('generate');
setGeneratorState((prev) => ({ ...prev, textToEncode: payload, inputError: '' })); setGeneratorState((prev) => ({
...prev,
step: 'input',
textToEncode: payload,
savedText: '',
qrCodeDataUrl: '',
inputError: '',
}));
}, []); }, []);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
@@ -107,20 +149,20 @@ export function useQrCode(): QrCodeContextValue {
); );
const downloadQrCode = useCallback(() => { const downloadQrCode = useCallback(() => {
if (!qrCodeDataUrl) return; if (!generatorState.qrCodeDataUrl) return;
const link = document.createElement('a'); const link = document.createElement('a');
link.href = qrCodeDataUrl; link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png'; link.download = 'qrcode.png';
link.click(); link.click();
toast.success(t('qrCode:qrCodeDownloadSuccess')); toast.success(t('qrCode:qrCodeDownloadSuccess'));
}, [qrCodeDataUrl, t]); }, [generatorState.qrCodeDataUrl, t]);
const copyQrCode = useCallback(async () => { const copyQrCode = useCallback(async () => {
if (!qrCodeDataUrl) return; if (!generatorState.qrCodeDataUrl) return;
try { try {
const response = await fetch(qrCodeDataUrl); const response = await fetch(generatorState.qrCodeDataUrl);
const blob = await response.blob(); const blob = await response.blob();
await navigator.clipboard.write([ await navigator.clipboard.write([
@@ -134,7 +176,7 @@ export function useQrCode(): QrCodeContextValue {
console.error('复制二维码失败:', error); console.error('复制二维码失败:', error);
toast.error(t('qrCode:copyError')); toast.error(t('qrCode:copyError'));
} }
}, [qrCodeDataUrl, t]); }, [generatorState.qrCodeDataUrl, t]);
const handleFileChange = useCallback( const handleFileChange = useCallback(
(file: File) => { (file: File) => {
@@ -178,8 +220,10 @@ export function useQrCode(): QrCodeContextValue {
return { return {
mode, mode,
setMode, setMode,
generatorState: fullGeneratorState, generatorState,
setTextToEncode, setTextToEncode,
confirmGenerate,
backToEdit,
parseQrCode, parseQrCode,
downloadQrCode, downloadQrCode,
copyQrCode, copyQrCode,
+11 -4
View File
@@ -5,16 +5,23 @@
/** 二维码功能核心主路由模式 */ /** 二维码功能核心主路由模式 */
export type QrCodeMode = 'generate' | 'parse'; export type QrCodeMode = 'generate' | 'parse';
/** 生成器的操作步骤 */
export type GeneratorStep = 'input' | 'preview';
/** * 二维码生成器的状态 /** * 二维码生成器的状态
* 💡 架构优化:保留与全局 Context 骨架契合的形态 * 💡 架构优化:采用两步操作模式(输入态 → 预览态)
* 外部依然可以流畅读取这些状态,但在新架构下运行效率和稳定性大幅提升 * 用户显式点击生成按钮后才生成二维码,体验更清晰
*/ */
export interface QrCodeGeneratorState { export interface QrCodeGeneratorState {
/** 当前操作步骤:输入态或预览态 */
step: GeneratorStep;
/** 受控的输入源文本(支持 URL 或任意文本快照) */ /** 受控的输入源文本(支持 URL 或任意文本快照) */
textToEncode: string; textToEncode: string;
/** 由防抖源文本流在单次渲染内存中同步派生出的二维码 Base64 Data URL */ /** 保存的文本快照,用于预览态展示和编辑态回填 */
savedText: string;
/** 由用户显式触发生成的二维码 Base64 Data URL */
qrCodeDataUrl: string; qrCodeDataUrl: string;
/** 是否正在生成(流式架构下已默认为恒定 false 的非阻塞快照,保留作为 UI 骨架兼容) */ /** 是否正在生成 */
generating: boolean; generating: boolean;
/** 输入文本校验或底层画布崩溃的错误提示信息 */ /** 输入文本校验或底层画布崩溃的错误提示信息 */
inputError: string; inputError: string;