refactor(qrcode): 提取独立组件并添加单元测试

- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)
This commit is contained in:
雨霖铃
2026-05-20 21:59:57 +08:00
parent 9c03c585a8
commit 2870e01610
8 changed files with 865 additions and 241 deletions
+42 -177
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useCallback } from 'react';
import {
Accordion,
AccordionDetails,
@@ -7,116 +7,66 @@ import {
Box,
Button,
CircularProgress,
IconButton,
InputAdornment,
Stack,
TextField,
Typography,
} from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import ImageIcon from '@mui/icons-material/Image';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ClearIcon from '@mui/icons-material/Clear';
import CopyButton from '@/components/CopyButton';
import ImageUploader from '@/components/ImageUploader';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useTranslation } from 'react-i18next';
interface QrCodeToUrlSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
/** 桌面端强制展开(隐藏折叠交互) */
forceExpanded?: boolean;
}
import type { QrCodeParserProps } from './types';
const QrCodeToUrlSection = ({
decodedResult,
onDecodedResultChange,
parsing,
onParsingChange,
parseError,
onParseErrorChange,
selectedFile,
onFileChange,
onClearFile,
previewUrl,
onPreviewUrlChange,
dragging,
onDraggingChange,
expanded,
onExpandedChange,
forceExpanded = false,
}: QrCodeToUrlSectionProps) => {
}: QrCodeParserProps) => {
const { t } = useTranslation(['qrCode']);
const { showMessage } = useSnackbar();
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [parsedUrl, setParsedUrl] = useState('');
const [parseError, setParseError] = useState('');
const [parsing, setParsing] = useState(false);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 清理预览 URL,防止内存泄漏
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
const handleFileChange = useCallback((file: File) => {
setQrCodeFile(file);
setPreviewUrl(URL.createObjectURL(file));
setParseError('');
setParsedUrl('');
}, []);
const handleClearFile = () => {
setQrCodeFile(null);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl('');
setParsedUrl('');
setParseError('');
showMessage(t('qrCode:imageCleared'), {
severity: 'success',
autoHideDuration: 1000,
});
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(true);
};
const handleDragLeave = () => {
setDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFileChange(droppedFile);
}
};
const handleClearFile = useCallback(() => {
onClearFile();
onDecodedResultChange('');
onParseErrorChange('');
}, [onClearFile, onDecodedResultChange, onParseErrorChange]);
const parseQrCode = async () => {
if (!qrCodeFile) {
if (!selectedFile) {
showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 });
return;
}
try {
setParsing(true);
setParseError('');
setParsedUrl('');
onParsingChange(true);
onParseErrorChange('');
onDecodedResultChange('');
const result = await parseQrCodeFromFile(qrCodeFile);
const result = await parseQrCodeFromFile(selectedFile);
if (result.success && result.data) {
setParsedUrl(result.data);
onDecodedResultChange(result.data);
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
} else {
onParseErrorChange(result.error || t('qrCode:noQrDetected'));
showMessage(result.error || t('qrCode:noQrDetected'), {
severity: 'error',
autoHideDuration: 1000,
@@ -124,49 +74,13 @@ const QrCodeToUrlSection = ({
}
} catch (error) {
console.error('解析二维码失败:', error);
onParseErrorChange(t('qrCode:parseError'));
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
} finally {
setParsing(false);
onParsingChange(false);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (!expanded) return;
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) {
try {
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
showMessage(t('qrCode:imagePasteError'), {
severity: 'error',
autoHideDuration: 3000,
});
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [expanded, showMessage, handleFileChange, t]);
return (
<Accordion
expanded={forceExpanded ? true : expanded}
@@ -183,64 +97,15 @@ const QrCodeToUrlSection = ({
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<Box
className="qr-flex-grow"
sx={qrCodePageStyles.DROPZONE(dragging, !!qrCodeFile)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleInputChange}
style={{ display: 'none' }}
id="qr-code-upload"
/>
<label
htmlFor="qr-code-upload"
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
>
{qrCodeFile ? (
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_WRAPPER}>
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_BOX}>
<img
src={previewUrl}
alt="QR Code Preview"
style={qrCodePageStyles.IMAGE_PREVIEW_IMG}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleClearFile();
}}
sx={qrCodePageStyles.CLEAR_BUTTON}
>
<ClearIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{qrCodeFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('qrCode:clickToChange')}
</Typography>
</Box>
) : (
<>
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t('qrCode:clickToUpload')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('qrCode:supportFormats')}
</Typography>
</>
)}
</label>
</Box>
<ImageUploader
selectedFile={selectedFile}
onFileChange={onFileChange}
onClearFile={handleClearFile}
previewUrl={previewUrl}
onPreviewUrlChange={onPreviewUrlChange}
dragging={dragging}
onDraggingChange={onDraggingChange}
/>
<Button
variant="contained"
@@ -255,7 +120,7 @@ const QrCodeToUrlSection = ({
<Box sx={qrCodePageStyles.RESULT_INPUT}>
<TextField
label={t('qrCode:resultLabel')}
value={parsedUrl}
value={decodedResult}
fullWidth
variant="outlined"
slotProps={{
@@ -264,7 +129,7 @@ const QrCodeToUrlSection = ({
endAdornment: (
<InputAdornment position="end">
<CopyButton
text={parsedUrl}
text={decodedResult}
tooltip={t('qrCode:copyTooltip')}
size="small"
color={qrCodePageStyles.primaryColor}
+33 -62
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import {
Accordion,
AccordionDetails,
@@ -11,39 +11,36 @@ import {
Typography,
} from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
import DownloadIcon from '@mui/icons-material/Download';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import QRious from 'qrious';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useTranslation } from 'react-i18next';
import { useContextMenuData } from '@/utils/useContextMenuData';
interface UrlToQrCodeSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
/** 桌面端强制展开(隐藏折叠交互) */
forceExpanded?: boolean;
}
import QrCodePreview from '@/components/QrCodePreview';
import type { QrCodeGeneratorProps } from './types';
const UrlToQrCodeSection = ({
textToEncode,
onTextChange,
qrCodeDataUrl,
onQrCodeDataUrlChange,
generating,
onGeneratingChange,
inputError,
onInputErrorChange,
expanded,
onExpandedChange,
forceExpanded = false,
}: UrlToQrCodeSectionProps) => {
}: QrCodeGeneratorProps) => {
const { t } = useTranslation(['qrCode']);
const { showMessage } = useSnackbar();
const [urlInput, setUrlInput] = useState('');
const [urlError, setUrlError] = useState('');
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [generating, setGenerating] = useState(false);
const generateQrCodeFromUrl = useCallback(
async (url: string) => {
try {
setGenerating(true);
setUrlError('');
onGeneratingChange(true);
onInputErrorChange('');
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
@@ -57,40 +54,40 @@ const UrlToQrCodeSection = ({
background: '#FFFFFF',
});
setQrCodeDataUrl(qr.toDataURL());
onQrCodeDataUrlChange(qr.toDataURL());
showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('生成二维码失败:', error);
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
} finally {
setGenerating(false);
onGeneratingChange(false);
}
},
[t, showMessage],
[t, showMessage, onGeneratingChange, onInputErrorChange, onQrCodeDataUrlChange],
);
const handleContextMenuData = useCallback(
(payload: string) => {
setUrlInput(payload);
onTextChange(payload);
onExpandedChange(true);
generateQrCodeFromUrl(payload);
},
[onExpandedChange, generateQrCodeFromUrl],
[onExpandedChange, generateQrCodeFromUrl, onTextChange],
);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrlInput(e.target.value);
setUrlError('');
onTextChange(e.target.value);
onInputErrorChange('');
};
const generateQrCode = async () => {
if (!urlInput) {
setUrlError(t('qrCode:enterUrlError'));
if (!textToEncode) {
onInputErrorChange(t('qrCode:enterUrlError'));
return;
}
await generateQrCodeFromUrl(urlInput);
await generateQrCodeFromUrl(textToEncode);
};
const downloadQrCode = () => {
@@ -142,12 +139,12 @@ const UrlToQrCodeSection = ({
<TextField
label={t('qrCode:urlInputLabel')}
placeholder={t('qrCode:urlInputPlaceholder')}
value={urlInput}
value={textToEncode}
onChange={handleUrlInputChange}
fullWidth
variant="outlined"
error={!!urlError}
helperText={urlError}
error={!!inputError}
helperText={inputError}
sx={qrCodePageStyles.INPUT_STYLE}
/>
@@ -161,38 +158,12 @@ const UrlToQrCodeSection = ({
{generating ? t('qrCode:generating') : t('qrCode:generateButton')}
</Button>
<Box className="qr-flex-grow" sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
{qrCodeDataUrl ? (
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={downloadQrCode}
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
>
{t('qrCode:downloadButton')}
</Button>
<Button
variant="contained"
startIcon={<ContentCopyIcon />}
onClick={copyQrCode}
sx={qrCodePageStyles.COPY_BUTTON}
>
{t('qrCode:copyQrButton')}
</Button>
</Box>
</Box>
) : (
<Typography
variant="body2"
color="text.secondary"
sx={qrCodePageStyles.PLACEHOLDER_TEXT}
>
{t('qrCode:qrCodeWillShow')}
</Typography>
)}
<Box className="qr-flex-grow">
<QrCodePreview
qrCodeDataUrl={qrCodeDataUrl}
onDownload={downloadQrCode}
onCopy={copyQrCode}
/>
</Box>
</Stack>
</AccordionDetails>
+122 -2
View File
@@ -1,3 +1,4 @@
import { useState, useCallback } from 'react';
import { Box, CircularProgress, Container, Stack, useMediaQuery, useTheme } from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
import UrlToQrCodeSection from '@/pages/QrCode/UrlToQrCodeSection';
@@ -6,6 +7,7 @@ import { useStorageState } from '@/utils/useStorageState';
import { qrCodePageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import type { QrCodeGeneratorState, QrCodeParserState } from './types';
export default function Index() {
const { t } = useLazyTranslation('qrCode');
@@ -16,6 +18,76 @@ export default function Index() {
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
// 二维码生成器状态
const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
textToEncode: '',
qrCodeDataUrl: '',
generating: false,
inputError: '',
});
// 二维码解析器状态
const [parserState, setParserState] = useState<QrCodeParserState>({
decodedResult: '',
parsing: false,
parseError: '',
selectedFile: null,
previewUrl: '',
dragging: false,
});
// 生成器状态更新函数
const setTextToEncode = useCallback((text: string) => {
setGeneratorState((prev) => ({ ...prev, textToEncode: text }));
}, []);
const setQrCodeDataUrl = useCallback((dataUrl: string) => {
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: dataUrl }));
}, []);
const setGenerating = useCallback((generating: boolean) => {
setGeneratorState((prev) => ({ ...prev, generating }));
}, []);
const setInputError = useCallback((error: string) => {
setGeneratorState((prev) => ({ ...prev, inputError: error }));
}, []);
// 解析器状态更新函数
const setDecodedResult = useCallback((result: string) => {
setParserState((prev) => ({ ...prev, decodedResult: result }));
}, []);
const setParsing = useCallback((parsing: boolean) => {
setParserState((prev) => ({ ...prev, parsing }));
}, []);
const setParseError = useCallback((error: string) => {
setParserState((prev) => ({ ...prev, parseError: error }));
}, []);
const setSelectedFile = useCallback((file: File | null) => {
setParserState((prev) => ({ ...prev, selectedFile: file }));
}, []);
const setPreviewUrl = useCallback((url: string) => {
setParserState((prev) => ({ ...prev, previewUrl: url }));
}, []);
const setDragging = useCallback((dragging: boolean) => {
setParserState((prev) => ({ ...prev, dragging }));
}, []);
const handleClearFile = useCallback(() => {
setParserState((prev) => ({
...prev,
selectedFile: null,
previewUrl: '',
decodedResult: '',
parseError: '',
}));
}, []);
// 初始化未完成时显示加载状态
if (!urlInitialized || !qrInitialized) {
return (
@@ -29,6 +101,14 @@ export default function Index() {
<>
<Box sx={qrCodePageStyles.GRID_CELL}>
<UrlToQrCodeSection
textToEncode={generatorState.textToEncode}
onTextChange={setTextToEncode}
qrCodeDataUrl={generatorState.qrCodeDataUrl}
onQrCodeDataUrlChange={setQrCodeDataUrl}
generating={generatorState.generating}
onGeneratingChange={setGenerating}
inputError={generatorState.inputError}
onInputErrorChange={setInputError}
expanded={urlExpanded}
onExpandedChange={setUrlExpanded}
forceExpanded={isDesktop}
@@ -36,6 +116,19 @@ export default function Index() {
</Box>
<Box sx={qrCodePageStyles.GRID_CELL}>
<QrCodeToUrlSection
decodedResult={parserState.decodedResult}
onDecodedResultChange={setDecodedResult}
parsing={parserState.parsing}
onParsingChange={setParsing}
parseError={parserState.parseError}
onParseErrorChange={setParseError}
selectedFile={parserState.selectedFile}
onFileChange={setSelectedFile}
onClearFile={handleClearFile}
previewUrl={parserState.previewUrl}
onPreviewUrlChange={setPreviewUrl}
dragging={parserState.dragging}
onDraggingChange={setDragging}
expanded={qrExpanded}
onExpandedChange={setQrExpanded}
forceExpanded={isDesktop}
@@ -44,8 +137,35 @@ export default function Index() {
</>
) : (
<>
<UrlToQrCodeSection expanded={urlExpanded} onExpandedChange={setUrlExpanded} />
<QrCodeToUrlSection expanded={qrExpanded} onExpandedChange={setQrExpanded} />
<UrlToQrCodeSection
textToEncode={generatorState.textToEncode}
onTextChange={setTextToEncode}
qrCodeDataUrl={generatorState.qrCodeDataUrl}
onQrCodeDataUrlChange={setQrCodeDataUrl}
generating={generatorState.generating}
onGeneratingChange={setGenerating}
inputError={generatorState.inputError}
onInputErrorChange={setInputError}
expanded={urlExpanded}
onExpandedChange={setUrlExpanded}
/>
<QrCodeToUrlSection
decodedResult={parserState.decodedResult}
onDecodedResultChange={setDecodedResult}
parsing={parserState.parsing}
onParsingChange={setParsing}
parseError={parserState.parseError}
onParseErrorChange={setParseError}
selectedFile={parserState.selectedFile}
onFileChange={setSelectedFile}
onClearFile={handleClearFile}
previewUrl={parserState.previewUrl}
onPreviewUrlChange={setPreviewUrl}
dragging={parserState.dragging}
onDraggingChange={setDragging}
expanded={qrExpanded}
onExpandedChange={setQrExpanded}
/>
</>
);
+124
View File
@@ -0,0 +1,124 @@
/**
* 二维码工具页面的状态类型定义
*/
/** 二维码生成模式 */
export type QrCodeMode = 'generate' | 'parse';
/** 二维码生成器的状态 */
export interface QrCodeGeneratorState {
/** 输入文本(URL 或任意文本) */
textToEncode: string;
/** 生成的二维码 Data URL */
qrCodeDataUrl: string;
/** 是否正在生成 */
generating: boolean;
/** 输入错误信息 */
inputError: string;
}
/** 二维码解析器的状态 */
export interface QrCodeParserState {
/** 解析结果文本 */
decodedResult: string;
/** 是否正在解析 */
parsing: boolean;
/** 解析错误信息 */
parseError: string;
/** 当前选中的文件 */
selectedFile: File | null;
/** 文件预览 URL */
previewUrl: string;
/** 是否正在拖拽 */
dragging: boolean;
}
/** 二维码生成器组件的 Props */
export interface QrCodeGeneratorProps {
/** 输入文本 */
textToEncode: string;
/** 输入文本变更回调 */
onTextChange: (text: string) => void;
/** 生成的二维码 Data URL */
qrCodeDataUrl: string;
/** 二维码 Data URL 变更回调 */
onQrCodeDataUrlChange: (dataUrl: string) => void;
/** 是否正在生成 */
generating: boolean;
/** 生成状态变更回调 */
onGeneratingChange: (generating: boolean) => void;
/** 输入错误信息 */
inputError: string;
/** 输入错误变更回调 */
onInputErrorChange: (error: string) => void;
/** 展开状态 */
expanded: boolean;
/** 展开状态变更回调 */
onExpandedChange: (expanded: boolean) => void;
/** 是否强制展开(桌面端) */
forceExpanded?: boolean;
}
/** 二维码预览组件的 Props */
export interface QrCodePreviewProps {
/** 二维码 Data URL */
qrCodeDataUrl: string;
/** 下载回调 */
onDownload: () => void;
/** 复制回调 */
onCopy: () => void;
/** 是否正在生成 */
generating?: boolean;
}
/** 图片上传器组件的 Props */
export interface ImageUploaderProps {
/** 选中的文件 */
selectedFile: File | null;
/** 文件变更回调 */
onFileChange: (file: File) => void;
/** 清除文件回调 */
onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string;
/** 是否正在拖拽 */
dragging: boolean;
/** 拖拽状态变更回调 */
onDraggingChange: (dragging: boolean) => void;
}
/** 二维码解析器组件的 Props */
export interface QrCodeParserProps {
/** 解析结果文本 */
decodedResult: string;
/** 解析结果变更回调 */
onDecodedResultChange: (result: string) => void;
/** 是否正在解析 */
parsing: boolean;
/** 解析状态变更回调 */
onParsingChange: (parsing: boolean) => void;
/** 解析错误信息 */
parseError: string;
/** 解析错误变更回调 */
onParseErrorChange: (error: string) => void;
/** 选中的文件 */
selectedFile: File | null;
/** 文件变更回调 */
onFileChange: (file: File) => void;
/** 清除文件回调 */
onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string;
/** 预览 URL 变更回调 */
onPreviewUrlChange: (url: string) => void;
/** 是否正在拖拽 */
dragging: boolean;
/** 拖拽状态变更回调 */
onDraggingChange: (dragging: boolean) => void;
/** 展开状态 */
expanded: boolean;
/** 展开状态变更回调 */
onExpandedChange: (expanded: boolean) => void;
/** 是否强制展开(桌面端) */
forceExpanded?: boolean;
}