refactor(qrcode): 组件化重构,采用 Context + Hook 模式

- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过
This commit is contained in:
雨霖铃
2026-05-21 00:07:42 +08:00
parent c5b51a9b33
commit df0b9440ca
8 changed files with 405 additions and 739 deletions
-157
View File
@@ -1,157 +0,0 @@
import { useCallback } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Button,
CircularProgress,
InputAdornment,
Stack,
TextField,
Typography,
} from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
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';
import type { QrCodeParserProps } from './types';
const QrCodeToUrlSection = ({
decodedResult,
onDecodedResultChange,
parsing,
onParsingChange,
parseError,
onParseErrorChange,
selectedFile,
onFileChange,
onClearFile,
previewUrl,
onPreviewUrlChange,
dragging,
onDraggingChange,
expanded,
onExpandedChange,
forceExpanded = false,
}: QrCodeParserProps) => {
const { t } = useTranslation(['qrCode']);
const { showMessage } = useSnackbar();
const handleClearFile = useCallback(() => {
onClearFile();
onDecodedResultChange('');
onParseErrorChange('');
}, [onClearFile, onDecodedResultChange, onParseErrorChange]);
const parseQrCode = async () => {
if (!selectedFile) {
showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 });
return;
}
try {
onParsingChange(true);
onParseErrorChange('');
onDecodedResultChange('');
const result = await parseQrCodeFromFile(selectedFile);
if (result.success && 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,
});
}
} catch (error) {
console.error('解析二维码失败:', error);
onParseErrorChange(t('qrCode:parseError'));
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
} finally {
onParsingChange(false);
}
};
return (
<Accordion
expanded={forceExpanded ? true : expanded}
onChange={forceExpanded ? undefined : (_, isExpanded) => onExpandedChange(isExpanded)}
sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={qrCodePageStyles.ACCORDION_SUMMARY}>
<Box sx={qrCodePageStyles.ACCORDION_TITLE_ICON}>
<LinkIcon color="success" />
<Typography variant="subtitle1" sx={qrCodePageStyles.ACCORDION_TITLE_TEXT}>
{t('qrCode:qrToUrl')}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<ImageUploader
selectedFile={selectedFile}
onFileChange={onFileChange}
onClearFile={handleClearFile}
previewUrl={previewUrl}
onPreviewUrlChange={onPreviewUrlChange}
dragging={dragging}
onDraggingChange={onDraggingChange}
/>
<Button
variant="contained"
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
onClick={parseQrCode}
disabled={parsing}
sx={qrCodePageStyles.PRIMARY_BUTTON}
>
{parsing ? t('qrCode:parsing') : t('qrCode:parseButton')}
</Button>
<Box sx={qrCodePageStyles.RESULT_INPUT}>
<TextField
label={t('qrCode:resultLabel')}
value={decodedResult}
fullWidth
variant="outlined"
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<CopyButton
text={decodedResult}
tooltip={t('qrCode:copyTooltip')}
size="small"
color={qrCodePageStyles.primaryColor}
showMessage={showMessage}
/>
</InputAdornment>
),
},
}}
sx={qrCodePageStyles.INPUT_STYLE}
/>
</Box>
{parseError && (
<Alert severity="error" sx={{ borderRadius: 3 }}>
{parseError}
</Alert>
)}
</Stack>
</AccordionDetails>
</Accordion>
);
};
export default QrCodeToUrlSection;
-174
View File
@@ -1,174 +0,0 @@
import React, { useCallback } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
CircularProgress,
Stack,
TextField,
Typography,
} from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
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';
import QrCodePreview from '@/components/QrCodePreview';
import type { QrCodeGeneratorProps } from './types';
const UrlToQrCodeSection = ({
textToEncode,
onTextChange,
qrCodeDataUrl,
onQrCodeDataUrlChange,
generating,
onGeneratingChange,
inputError,
onInputErrorChange,
expanded,
onExpandedChange,
forceExpanded = false,
}: QrCodeGeneratorProps) => {
const { t } = useTranslation(['qrCode']);
const { showMessage } = useSnackbar();
const generateQrCodeFromUrl = useCallback(
async (url: string) => {
try {
onGeneratingChange(true);
onInputErrorChange('');
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const qr = new QRious({
value: url,
size: 250,
level: 'H',
foreground: '#000000',
background: '#FFFFFF',
});
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 {
onGeneratingChange(false);
}
},
[t, showMessage, onGeneratingChange, onInputErrorChange, onQrCodeDataUrlChange],
);
const handleContextMenuData = useCallback(
(payload: string) => {
onTextChange(payload);
onExpandedChange(true);
generateQrCodeFromUrl(payload);
},
[onExpandedChange, generateQrCodeFromUrl, onTextChange],
);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onTextChange(e.target.value);
onInputErrorChange('');
};
const generateQrCode = async () => {
if (!textToEncode) {
onInputErrorChange(t('qrCode:enterUrlError'));
return;
}
await generateQrCodeFromUrl(textToEncode);
};
const downloadQrCode = () => {
if (!qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 300 });
};
const copyQrCode = async () => {
if (!qrCodeDataUrl) return;
try {
const response = await fetch(qrCodeDataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
}
};
return (
<Accordion
expanded={forceExpanded ? true : expanded}
onChange={forceExpanded ? undefined : (_, isExpanded) => onExpandedChange(isExpanded)}
sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={qrCodePageStyles.ACCORDION_SUMMARY}>
<Box sx={qrCodePageStyles.ACCORDION_TITLE_ICON}>
<QrCodeIcon color="primary" />
<Typography variant="subtitle1" sx={qrCodePageStyles.ACCORDION_TITLE_TEXT}>
{t('qrCode:urlToQr')}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<TextField
label={t('qrCode:urlInputLabel')}
placeholder={t('qrCode:urlInputPlaceholder')}
value={textToEncode}
onChange={handleUrlInputChange}
fullWidth
variant="outlined"
error={!!inputError}
helperText={inputError}
sx={qrCodePageStyles.INPUT_STYLE}
/>
<Button
variant="contained"
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
onClick={generateQrCode}
disabled={generating}
sx={qrCodePageStyles.PRIMARY_BUTTON}
>
{generating ? t('qrCode:generating') : t('qrCode:generateButton')}
</Button>
<Box className="qr-flex-grow">
<QrCodePreview
qrCodeDataUrl={qrCodeDataUrl}
onDownload={downloadQrCode}
onCopy={copyQrCode}
/>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
);
};
export default UrlToQrCodeSection;
+37
View File
@@ -0,0 +1,37 @@
import { Grid } from '@mui/material';
import TextInputArea from '@/components/TextInputArea';
import QrCodePreview from '@/components/QrCodePreview';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useQrCodeContext } from '../contexts/QrCodeContext';
export default function GeneratePanel() {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextInputArea
title={t('qrCode:urlInputLabel')}
value={generatorState.textToEncode}
onChange={setTextToEncode}
placeholder={t('qrCode:urlInputPlaceholder')}
showCount
showClear
allowCopy
externalError={generatorState.inputError}
showMessage={showMessage}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<QrCodePreview
qrCodeDataUrl={generatorState.qrCodeDataUrl}
onDownload={downloadQrCode}
onCopy={copyQrCode}
/>
</Grid>
</Grid>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useCallback } from 'react';
import { Grid } from '@mui/material';
import TextInputArea from '@/components/TextInputArea';
import ImageUploader from '@/components/ImageUploader';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useQrCodeContext } from '../contexts/QrCodeContext';
export default function ParsePanel() {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const { parserState, setParserState, parseQrCode, handleFileChange, handleClearFile } =
useQrCodeContext();
// 全局粘贴事件监听
const handlePaste = useCallback(
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 {
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 });
}
}
},
[handleFileChange, showMessage, t],
);
useEffect(() => {
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [handlePaste]);
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<ImageUploader
selectedFile={parserState.selectedFile}
onFileChange={handleFileChange}
onClearFile={handleClearFile}
previewUrl={parserState.previewUrl}
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
dragging={parserState.dragging}
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextInputArea
title={t('qrCode:resultLabel')}
value={parserState.decodedResult}
readOnly
showClear={false}
allowCopy
externalError={parserState.parseError}
actions={[
{
key: 'parse',
label: parserState.parsing ? t('qrCode:parsing') : t('qrCode:parseButton'),
type: 'primary',
position: 'bottom',
disabled: !parserState.selectedFile || parserState.parsing,
onClick: () => {
if (parserState.selectedFile) {
parseQrCode(parserState.selectedFile);
}
},
},
]}
showMessage={showMessage}
/>
</Grid>
</Grid>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext } from 'react';
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from '../types';
export interface QrCodeContextValue {
// 模式
mode: QrCodeMode;
setMode: (mode: QrCodeMode) => void;
// 生成器状态
generatorState: QrCodeGeneratorState;
setTextToEncode: (text: string) => void;
generateQrCode: (text: string) => Promise<void>;
downloadQrCode: () => void;
copyQrCode: () => Promise<void>;
// 解析器状态
parserState: QrCodeParserState;
setParserState: React.Dispatch<React.SetStateAction<QrCodeParserState>>;
parseQrCode: (file: File) => Promise<void>;
handleFileChange: (file: File) => void;
handleClearFile: () => void;
}
export const QrCodeContext = createContext<QrCodeContextValue | null>(null);
export function useQrCodeContext() {
const context = useContext(QrCodeContext);
if (!context) {
throw new Error('useQrCodeContext must be used within QrCodeProvider');
}
return context;
}
+206
View File
@@ -0,0 +1,206 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import QRious from 'qrious';
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 { QrCodeContextValue } from '../contexts/QrCodeContext';
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from '../types';
export function useQrCode(): QrCodeContextValue {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
// 当前模式
const [mode, setMode] = useState<QrCodeMode>('generate');
// 二维码生成器状态
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 generateQrCode = useCallback(
async (text: string) => {
if (!text) {
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: '' }));
return;
}
try {
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
let url = text;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const qr = new QRious({
value: url,
size: 250,
level: 'H',
foreground: '#000000',
background: '#FFFFFF',
});
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: qr.toDataURL() }));
} catch (error) {
console.error('生成二维码失败:', error);
setGeneratorState((prev) => ({
...prev,
inputError: t('qrCode:generateError'),
}));
showMessage(t('qrCode:generateError'), { severity: 'error', autoHideDuration: 3000 });
} finally {
setGeneratorState((prev) => ({ ...prev, generating: false }));
}
},
[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 setTextToEncode = useCallback((text: string) => {
setGeneratorState((prev) => ({ ...prev, textToEncode: text }));
}, []);
// 处理右键菜单数据
const handleContextMenuData = useCallback((payload: string) => {
setMode('generate');
setGeneratorState((prev) => ({ ...prev, textToEncode: payload }));
}, []);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
// 解析二维码
const parseQrCode = useCallback(
async (file: File) => {
try {
setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' }));
const result = await parseQrCodeFromFile(file);
if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
} else {
const errorMsg = result.error || t('qrCode:noQrDetected');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
}
} catch (error) {
console.error('解析二维码失败:', error);
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 }));
}
},
[t, showMessage],
);
// 下载二维码
const downloadQrCode = useCallback(() => {
if (!generatorState.qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
}, [generatorState.qrCodeDataUrl, showMessage, t]);
// 复制二维码
const copyQrCode = useCallback(async () => {
if (!generatorState.qrCodeDataUrl) return;
try {
const response = await fetch(generatorState.qrCodeDataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
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) {
URL.revokeObjectURL(parserState.previewUrl);
}
setParserState((prev) => ({
...prev,
selectedFile: null,
previewUrl: '',
decodedResult: '',
parseError: '',
}));
}, [parserState.previewUrl]);
return {
mode,
setMode,
generatorState,
setTextToEncode,
generateQrCode,
downloadQrCode,
copyQrCode,
parserState,
setParserState,
parseQrCode,
handleFileChange,
handleClearFile,
};
}
+16 -304
View File
@@ -1,242 +1,20 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { Box, Container, Grid, useMediaQuery, useTheme } from '@mui/material';
import { Box, Container, useMediaQuery, useTheme } from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
import QRious from 'qrious';
import PageHeader from '@/components/PageHeader';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import TextInputArea from '@/components/TextInputArea';
import QrCodePreview from '@/components/QrCodePreview';
import ImageUploader from '@/components/ImageUploader';
import { qrCodePageStyles } from '@/config/pageTheme';
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';
import { QrCodeContext } from './contexts/QrCodeContext';
import { useQrCode } from './hooks/useQrCode';
import GeneratePanel from './components/GeneratePanel';
import ParsePanel from './components/ParsePanel';
import type { QrCodeMode } from './types';
export default function Index() {
const { t } = useLazyTranslation('qrCode');
const theme = useTheme();
const { showMessage } = useSnackbar();
const isDesktop = useMediaQuery(theme.breakpoints.up('md'));
// 当前模式
const [mode, setMode] = useState<QrCodeMode>('generate');
// 二维码生成器状态
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 generateQrCode = useCallback(
async (text: string) => {
if (!text) {
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: '' }));
return;
}
try {
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
let url = text;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const qr = new QRious({
value: url,
size: 250,
level: 'H',
foreground: '#000000',
background: '#FFFFFF',
});
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: qr.toDataURL() }));
} catch (error) {
console.error('生成二维码失败:', error);
setGeneratorState((prev) => ({
...prev,
inputError: t('qrCode:generateError'),
}));
showMessage(t('qrCode:generateError'), { severity: 'error', autoHideDuration: 3000 });
} finally {
setGeneratorState((prev) => ({ ...prev, generating: false }));
}
},
[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 }));
}, []);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
// 解析二维码
const parseQrCode = useCallback(
async (file: File) => {
try {
setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' }));
const result = await parseQrCodeFromFile(file);
if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
} else {
const errorMsg = result.error || t('qrCode:noQrDetected');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
}
} catch (error) {
console.error('解析二维码失败:', error);
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 }));
}
},
[t, showMessage],
);
// 下载二维码
const downloadQrCode = useCallback(() => {
if (!generatorState.qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
}, [generatorState.qrCodeDataUrl, showMessage, t]);
// 复制二维码
const copyQrCode = useCallback(async () => {
if (!generatorState.qrCodeDataUrl) return;
try {
const response = await fetch(generatorState.qrCodeDataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
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) {
URL.revokeObjectURL(parserState.previewUrl);
}
setParserState((prev) => ({
...prev,
selectedFile: null,
previewUrl: '',
decodedResult: '',
parseError: '',
}));
}, [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 qrCode = useQrCode();
// 模式选项
const modeOptions = [
@@ -244,77 +22,8 @@ export default function Index() {
{ value: 'parse' as QrCodeMode, label: t('qrCode:qrToUrl') },
];
// 渲染左侧面板
const renderLeftPanel = () => {
if (mode === 'generate') {
return (
<TextInputArea
title={t('qrCode:urlInputLabel')}
value={generatorState.textToEncode}
onChange={(text) => setGeneratorState((prev) => ({ ...prev, textToEncode: text }))}
placeholder={t('qrCode:urlInputPlaceholder')}
showCount
showClear
allowCopy
externalError={generatorState.inputError}
showMessage={showMessage}
/>
);
}
return (
<ImageUploader
selectedFile={parserState.selectedFile}
onFileChange={handleFileChange}
onClearFile={handleClearFile}
previewUrl={parserState.previewUrl}
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
dragging={parserState.dragging}
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
/>
);
};
// 渲染右侧面板
const renderRightPanel = () => {
if (mode === 'generate') {
return (
<QrCodePreview
qrCodeDataUrl={generatorState.qrCodeDataUrl}
onDownload={downloadQrCode}
onCopy={copyQrCode}
/>
);
}
return (
<TextInputArea
title={t('qrCode:resultLabel')}
value={parserState.decodedResult}
readOnly
showClear={false}
allowCopy
externalError={parserState.parseError}
actions={[
{
key: 'parse',
label: parserState.parsing ? t('qrCode:parsing') : t('qrCode:parseButton'),
type: 'primary',
position: 'bottom',
disabled: !parserState.selectedFile || parserState.parsing,
onClick: () => {
if (parserState.selectedFile) {
parseQrCode(parserState.selectedFile);
}
},
},
]}
showMessage={showMessage}
/>
);
};
return (
<QrCodeContext.Provider value={qrCode}>
<Box>
<Container
maxWidth={isDesktop ? 'lg' : false}
@@ -328,13 +37,16 @@ export default function Index() {
sx={{ mb: 2.5 }}
/>
<SwitchButtonGroup value={mode} options={modeOptions} onChange={setMode} sx={{ mb: 3 }} />
<SwitchButtonGroup
value={qrCode.mode}
options={modeOptions}
onChange={qrCode.setMode}
sx={{ mb: 3 }}
/>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>{renderLeftPanel()}</Grid>
<Grid size={{ xs: 12, md: 6 }}>{renderRightPanel()}</Grid>
</Grid>
{qrCode.mode === 'generate' ? <GeneratePanel /> : <ParsePanel />}
</Container>
</Box>
</QrCodeContext.Provider>
);
}
-90
View File
@@ -32,93 +32,3 @@ export interface QrCodeParserState {
/** 是否正在拖拽 */
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;
}