refactor(qrcode): 提取独立组件并添加单元测试
- 创建 pages/QrCode/types.ts 定义状态类型接口 - 提取 QrCodePreview 组件用于二维码预览和操作 - 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑 - 重构 UrlToQrCodeSection 状态提升到父组件 - 重构 QrCodeToUrlSection 通过回调传递解析结果 - 更新主页面 index.tsx 集中管理所有状态 - 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)
This commit is contained in:
@@ -0,0 +1,188 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Box, IconButton, Typography } from '@mui/material';
|
||||||
|
import ImageIcon from '@mui/icons-material/Image';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface ImageUploaderProps {
|
||||||
|
/** 选中的文件 */
|
||||||
|
selectedFile: File | null;
|
||||||
|
/** 文件变更回调 */
|
||||||
|
onFileChange: (file: File) => void;
|
||||||
|
/** 清除文件回调 */
|
||||||
|
onClearFile: () => void;
|
||||||
|
/** 文件预览 URL */
|
||||||
|
previewUrl: string;
|
||||||
|
/** 预览 URL 变更回调 */
|
||||||
|
onPreviewUrlChange: (url: string) => void;
|
||||||
|
/** 是否正在拖拽 */
|
||||||
|
dragging: boolean;
|
||||||
|
/** 拖拽状态变更回调 */
|
||||||
|
onDraggingChange: (dragging: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageUploader = ({
|
||||||
|
selectedFile,
|
||||||
|
onFileChange,
|
||||||
|
onClearFile,
|
||||||
|
previewUrl,
|
||||||
|
onPreviewUrlChange,
|
||||||
|
dragging,
|
||||||
|
onDraggingChange,
|
||||||
|
}: ImageUploaderProps) => {
|
||||||
|
const { t } = useTranslation(['qrCode']);
|
||||||
|
const { showMessage } = useSnackbar();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 清理预览 URL,防止内存泄漏
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
onFileChange(file);
|
||||||
|
onPreviewUrlChange(URL.createObjectURL(file));
|
||||||
|
},
|
||||||
|
[onFileChange, onPreviewUrlChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearFile = () => {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
onClearFile();
|
||||||
|
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();
|
||||||
|
onDraggingChange(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
onDraggingChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDraggingChange(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) {
|
||||||
|
handleFileChange(droppedFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听粘贴事件
|
||||||
|
useEffect(() => {
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}, [showMessage, handleFileChange, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className="qr-flex-grow"
|
||||||
|
sx={qrCodePageStyles.DROPZONE(dragging, !!selectedFile)}
|
||||||
|
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%' }}
|
||||||
|
>
|
||||||
|
{selectedFile ? (
|
||||||
|
<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 }}>
|
||||||
|
{selectedFile.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUploader;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface QrCodePreviewProps {
|
||||||
|
/** 二维码 Data URL */
|
||||||
|
qrCodeDataUrl: string;
|
||||||
|
/** 下载回调 */
|
||||||
|
onDownload: () => void;
|
||||||
|
/** 复制回调 */
|
||||||
|
onCopy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QrCodePreview = ({ qrCodeDataUrl, onDownload, onCopy }: QrCodePreviewProps) => {
|
||||||
|
const { t } = useTranslation(['qrCode']);
|
||||||
|
|
||||||
|
if (!qrCodeDataUrl) {
|
||||||
|
return (
|
||||||
|
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={qrCodePageStyles.PLACEHOLDER_TEXT}>
|
||||||
|
{t('qrCode:qrCodeWillShow')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
||||||
|
<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={onDownload}
|
||||||
|
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
|
||||||
|
>
|
||||||
|
{t('qrCode:downloadButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<ContentCopyIcon />}
|
||||||
|
onClick={onCopy}
|
||||||
|
sx={qrCodePageStyles.COPY_BUTTON}
|
||||||
|
>
|
||||||
|
{t('qrCode:copyQrButton')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QrCodePreview;
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import ImageUploader from '@/components/ImageUploader';
|
||||||
|
|
||||||
|
// 模拟 URL API
|
||||||
|
const mockCreateObjectURL = vi.fn();
|
||||||
|
const mockRevokeObjectURL = vi.fn();
|
||||||
|
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||||
|
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||||
|
|
||||||
|
// 模拟 showMessage
|
||||||
|
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||||
|
useSnackbar: () => ({
|
||||||
|
showMessage: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ImageUploader 组件', () => {
|
||||||
|
const mockOnFileChange = vi.fn();
|
||||||
|
const mockOnClearFile = vi.fn();
|
||||||
|
const mockOnPreviewUrlChange = vi.fn();
|
||||||
|
const mockOnDraggingChange = vi.fn();
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectedFile: null,
|
||||||
|
onFileChange: mockOnFileChange,
|
||||||
|
onClearFile: mockOnClearFile,
|
||||||
|
previewUrl: '',
|
||||||
|
onPreviewUrlChange: mockOnPreviewUrlChange,
|
||||||
|
dragging: false,
|
||||||
|
onDraggingChange: mockOnDraggingChange,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreateObjectURL.mockReturnValue('blob:test-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('当没有选中文件时应显示上传提示', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示文件预览', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示预览图片', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
const img = screen.getByAltText('QR Code Preview');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute('src', 'blob:test-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示清除按钮', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应包含隐藏的文件输入框', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input).toHaveAttribute('type', 'file');
|
||||||
|
expect(input).toHaveAttribute('accept', 'image/*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('文件选择交互', () => {
|
||||||
|
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { files: [mockFile] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
||||||
|
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('拖拽交互', () => {
|
||||||
|
it('拖拽进入时应调用 onDraggingChange(true)', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.dragOver(dropzone);
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拖拽离开时应调用 onDraggingChange(false)', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.dragLeave(dropzone);
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
const dropEvent = new Event('drop', { bubbles: true });
|
||||||
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||||
|
value: {
|
||||||
|
files: [mockFile],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(dropzone, dropEvent);
|
||||||
|
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('清除文件功能', () => {
|
||||||
|
it('点击清除按钮时应调用 onClearFile', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
|
||||||
|
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||||
|
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('清除文件时应撤销预览 URL', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
|
||||||
|
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('粘贴功能', () => {
|
||||||
|
it('监听粘贴事件', () => {
|
||||||
|
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组件卸载时应移除粘贴事件监听', () => {
|
||||||
|
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||||
|
const { unmount } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('样式测试', () => {
|
||||||
|
it('拖拽状态时应应用拖拽样式', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
expect(dropzone).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有文件时应应用有文件样式', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
const { container } = render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
expect(dropzone).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import QrCodePreview from '@/components/QrCodePreview';
|
||||||
|
|
||||||
|
describe('QrCodePreview 组件', () => {
|
||||||
|
const mockOnDownload = vi.fn();
|
||||||
|
const mockOnCopy = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
||||||
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
|
expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||||
|
const testDataUrl = 'data:image/png;base64,test123';
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl={testDataUrl}
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const img = screen.getByAltText('QR Code');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute('src', testDataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||||
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
|
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击下载按钮时应调用 onDownload 回调', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('qrCode:downloadButton'));
|
||||||
|
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击复制按钮时应调用 onCopy 回调', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('qrCode:copyQrButton'));
|
||||||
|
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
@@ -7,116 +7,66 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
IconButton,
|
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
import ImageIcon from '@mui/icons-material/Image';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
|
import ImageUploader from '@/components/ImageUploader';
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { QrCodeParserProps } from './types';
|
||||||
interface QrCodeToUrlSectionProps {
|
|
||||||
expanded: boolean;
|
|
||||||
onExpandedChange: (expanded: boolean) => void;
|
|
||||||
/** 桌面端强制展开(隐藏折叠交互) */
|
|
||||||
forceExpanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QrCodeToUrlSection = ({
|
const QrCodeToUrlSection = ({
|
||||||
|
decodedResult,
|
||||||
|
onDecodedResultChange,
|
||||||
|
parsing,
|
||||||
|
onParsingChange,
|
||||||
|
parseError,
|
||||||
|
onParseErrorChange,
|
||||||
|
selectedFile,
|
||||||
|
onFileChange,
|
||||||
|
onClearFile,
|
||||||
|
previewUrl,
|
||||||
|
onPreviewUrlChange,
|
||||||
|
dragging,
|
||||||
|
onDraggingChange,
|
||||||
expanded,
|
expanded,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
forceExpanded = false,
|
forceExpanded = false,
|
||||||
}: QrCodeToUrlSectionProps) => {
|
}: QrCodeParserProps) => {
|
||||||
const { t } = useTranslation(['qrCode']);
|
const { t } = useTranslation(['qrCode']);
|
||||||
const { showMessage } = useSnackbar();
|
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);
|
const handleClearFile = useCallback(() => {
|
||||||
|
onClearFile();
|
||||||
// 清理预览 URL,防止内存泄漏
|
onDecodedResultChange('');
|
||||||
useEffect(() => {
|
onParseErrorChange('');
|
||||||
return () => {
|
}, [onClearFile, onDecodedResultChange, onParseErrorChange]);
|
||||||
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 parseQrCode = async () => {
|
const parseQrCode = async () => {
|
||||||
if (!qrCodeFile) {
|
if (!selectedFile) {
|
||||||
showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 });
|
showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setParsing(true);
|
onParsingChange(true);
|
||||||
setParseError('');
|
onParseErrorChange('');
|
||||||
setParsedUrl('');
|
onDecodedResultChange('');
|
||||||
|
|
||||||
const result = await parseQrCodeFromFile(qrCodeFile);
|
const result = await parseQrCodeFromFile(selectedFile);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setParsedUrl(result.data);
|
onDecodedResultChange(result.data);
|
||||||
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
} else {
|
} else {
|
||||||
|
onParseErrorChange(result.error || t('qrCode:noQrDetected'));
|
||||||
showMessage(result.error || t('qrCode:noQrDetected'), {
|
showMessage(result.error || t('qrCode:noQrDetected'), {
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
autoHideDuration: 1000,
|
autoHideDuration: 1000,
|
||||||
@@ -124,49 +74,13 @@ const QrCodeToUrlSection = ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('解析二维码失败:', error);
|
console.error('解析二维码失败:', error);
|
||||||
|
onParseErrorChange(t('qrCode:parseError'));
|
||||||
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<Accordion
|
<Accordion
|
||||||
expanded={forceExpanded ? true : expanded}
|
expanded={forceExpanded ? true : expanded}
|
||||||
@@ -183,64 +97,15 @@ const QrCodeToUrlSection = ({
|
|||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Box
|
<ImageUploader
|
||||||
className="qr-flex-grow"
|
selectedFile={selectedFile}
|
||||||
sx={qrCodePageStyles.DROPZONE(dragging, !!qrCodeFile)}
|
onFileChange={onFileChange}
|
||||||
onDragOver={handleDragOver}
|
onClearFile={handleClearFile}
|
||||||
onDragLeave={handleDragLeave}
|
previewUrl={previewUrl}
|
||||||
onDrop={handleDrop}
|
onPreviewUrlChange={onPreviewUrlChange}
|
||||||
>
|
dragging={dragging}
|
||||||
<input
|
onDraggingChange={onDraggingChange}
|
||||||
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>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -255,7 +120,7 @@ const QrCodeToUrlSection = ({
|
|||||||
<Box sx={qrCodePageStyles.RESULT_INPUT}>
|
<Box sx={qrCodePageStyles.RESULT_INPUT}>
|
||||||
<TextField
|
<TextField
|
||||||
label={t('qrCode:resultLabel')}
|
label={t('qrCode:resultLabel')}
|
||||||
value={parsedUrl}
|
value={decodedResult}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -264,7 +129,7 @@ const QrCodeToUrlSection = ({
|
|||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={parsedUrl}
|
text={decodedResult}
|
||||||
tooltip={t('qrCode:copyTooltip')}
|
tooltip={t('qrCode:copyTooltip')}
|
||||||
size="small"
|
size="small"
|
||||||
color={qrCodePageStyles.primaryColor}
|
color={qrCodePageStyles.primaryColor}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
@@ -11,39 +11,36 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
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 ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import QRious from 'qrious';
|
import QRious from 'qrious';
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
import QrCodePreview from '@/components/QrCodePreview';
|
||||||
interface UrlToQrCodeSectionProps {
|
import type { QrCodeGeneratorProps } from './types';
|
||||||
expanded: boolean;
|
|
||||||
onExpandedChange: (expanded: boolean) => void;
|
|
||||||
/** 桌面端强制展开(隐藏折叠交互) */
|
|
||||||
forceExpanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlToQrCodeSection = ({
|
const UrlToQrCodeSection = ({
|
||||||
|
textToEncode,
|
||||||
|
onTextChange,
|
||||||
|
qrCodeDataUrl,
|
||||||
|
onQrCodeDataUrlChange,
|
||||||
|
generating,
|
||||||
|
onGeneratingChange,
|
||||||
|
inputError,
|
||||||
|
onInputErrorChange,
|
||||||
expanded,
|
expanded,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
forceExpanded = false,
|
forceExpanded = false,
|
||||||
}: UrlToQrCodeSectionProps) => {
|
}: QrCodeGeneratorProps) => {
|
||||||
const { t } = useTranslation(['qrCode']);
|
const { t } = useTranslation(['qrCode']);
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
const [urlInput, setUrlInput] = useState('');
|
|
||||||
const [urlError, setUrlError] = useState('');
|
|
||||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
|
|
||||||
const generateQrCodeFromUrl = useCallback(
|
const generateQrCodeFromUrl = useCallback(
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
try {
|
try {
|
||||||
setGenerating(true);
|
onGeneratingChange(true);
|
||||||
setUrlError('');
|
onInputErrorChange('');
|
||||||
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
url = 'https://' + url;
|
url = 'https://' + url;
|
||||||
@@ -57,40 +54,40 @@ const UrlToQrCodeSection = ({
|
|||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
});
|
});
|
||||||
|
|
||||||
setQrCodeDataUrl(qr.toDataURL());
|
onQrCodeDataUrlChange(qr.toDataURL());
|
||||||
showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('生成二维码失败:', error);
|
console.error('生成二维码失败:', error);
|
||||||
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
onGeneratingChange(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t, showMessage],
|
[t, showMessage, onGeneratingChange, onInputErrorChange, onQrCodeDataUrlChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContextMenuData = useCallback(
|
const handleContextMenuData = useCallback(
|
||||||
(payload: string) => {
|
(payload: string) => {
|
||||||
setUrlInput(payload);
|
onTextChange(payload);
|
||||||
onExpandedChange(true);
|
onExpandedChange(true);
|
||||||
generateQrCodeFromUrl(payload);
|
generateQrCodeFromUrl(payload);
|
||||||
},
|
},
|
||||||
[onExpandedChange, generateQrCodeFromUrl],
|
[onExpandedChange, generateQrCodeFromUrl, onTextChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
|
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
|
||||||
|
|
||||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setUrlInput(e.target.value);
|
onTextChange(e.target.value);
|
||||||
setUrlError('');
|
onInputErrorChange('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateQrCode = async () => {
|
const generateQrCode = async () => {
|
||||||
if (!urlInput) {
|
if (!textToEncode) {
|
||||||
setUrlError(t('qrCode:enterUrlError'));
|
onInputErrorChange(t('qrCode:enterUrlError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await generateQrCodeFromUrl(urlInput);
|
await generateQrCodeFromUrl(textToEncode);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadQrCode = () => {
|
const downloadQrCode = () => {
|
||||||
@@ -142,12 +139,12 @@ const UrlToQrCodeSection = ({
|
|||||||
<TextField
|
<TextField
|
||||||
label={t('qrCode:urlInputLabel')}
|
label={t('qrCode:urlInputLabel')}
|
||||||
placeholder={t('qrCode:urlInputPlaceholder')}
|
placeholder={t('qrCode:urlInputPlaceholder')}
|
||||||
value={urlInput}
|
value={textToEncode}
|
||||||
onChange={handleUrlInputChange}
|
onChange={handleUrlInputChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
error={!!urlError}
|
error={!!inputError}
|
||||||
helperText={urlError}
|
helperText={inputError}
|
||||||
sx={qrCodePageStyles.INPUT_STYLE}
|
sx={qrCodePageStyles.INPUT_STYLE}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -161,38 +158,12 @@ const UrlToQrCodeSection = ({
|
|||||||
{generating ? t('qrCode:generating') : t('qrCode:generateButton')}
|
{generating ? t('qrCode:generating') : t('qrCode:generateButton')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Box className="qr-flex-grow" sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
<Box className="qr-flex-grow">
|
||||||
{qrCodeDataUrl ? (
|
<QrCodePreview
|
||||||
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
|
qrCodeDataUrl={qrCodeDataUrl}
|
||||||
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
|
onDownload={downloadQrCode}
|
||||||
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
|
onCopy={copyQrCode}
|
||||||
<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>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
|
|||||||
+122
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
import { Box, CircularProgress, Container, Stack, useMediaQuery, useTheme } from '@mui/material';
|
import { Box, CircularProgress, Container, Stack, useMediaQuery, useTheme } from '@mui/material';
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||||
import UrlToQrCodeSection from '@/pages/QrCode/UrlToQrCodeSection';
|
import UrlToQrCodeSection from '@/pages/QrCode/UrlToQrCodeSection';
|
||||||
@@ -6,6 +7,7 @@ import { useStorageState } from '@/utils/useStorageState';
|
|||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import type { QrCodeGeneratorState, QrCodeParserState } from './types';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useLazyTranslation('qrCode');
|
const { t } = useLazyTranslation('qrCode');
|
||||||
@@ -16,6 +18,76 @@ export default function Index() {
|
|||||||
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
|
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
|
||||||
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
|
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) {
|
if (!urlInitialized || !qrInitialized) {
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +101,14 @@ export default function Index() {
|
|||||||
<>
|
<>
|
||||||
<Box sx={qrCodePageStyles.GRID_CELL}>
|
<Box sx={qrCodePageStyles.GRID_CELL}>
|
||||||
<UrlToQrCodeSection
|
<UrlToQrCodeSection
|
||||||
|
textToEncode={generatorState.textToEncode}
|
||||||
|
onTextChange={setTextToEncode}
|
||||||
|
qrCodeDataUrl={generatorState.qrCodeDataUrl}
|
||||||
|
onQrCodeDataUrlChange={setQrCodeDataUrl}
|
||||||
|
generating={generatorState.generating}
|
||||||
|
onGeneratingChange={setGenerating}
|
||||||
|
inputError={generatorState.inputError}
|
||||||
|
onInputErrorChange={setInputError}
|
||||||
expanded={urlExpanded}
|
expanded={urlExpanded}
|
||||||
onExpandedChange={setUrlExpanded}
|
onExpandedChange={setUrlExpanded}
|
||||||
forceExpanded={isDesktop}
|
forceExpanded={isDesktop}
|
||||||
@@ -36,6 +116,19 @@ export default function Index() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={qrCodePageStyles.GRID_CELL}>
|
<Box sx={qrCodePageStyles.GRID_CELL}>
|
||||||
<QrCodeToUrlSection
|
<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}
|
expanded={qrExpanded}
|
||||||
onExpandedChange={setQrExpanded}
|
onExpandedChange={setQrExpanded}
|
||||||
forceExpanded={isDesktop}
|
forceExpanded={isDesktop}
|
||||||
@@ -44,8 +137,35 @@ export default function Index() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<UrlToQrCodeSection expanded={urlExpanded} onExpandedChange={setUrlExpanded} />
|
<UrlToQrCodeSection
|
||||||
<QrCodeToUrlSection expanded={qrExpanded} onExpandedChange={setQrExpanded} />
|
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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user