Develop fill form (#11)

* feat(formRecognizer): 添加表单识别功能及相关组件

添加表单识别功能,包括以下内容:
1. 在路由配置中添加表单识别页面
2. 实现表单识别页面和侧边栏面板
3. 添加表单数据生成工具类
4. 实现与内容脚本的通信机制
5. 添加faker-js依赖用于生成测试数据
6. 支持不同入口点(popup/sidepanel)的组件渲染

* refactor(消息通信): 重构消息通信机制并集中管理消息协议

将分散的消息协议和通信逻辑集中到 utils/messages.ts 中
移除旧的 messages.tsx 文件并更新相关引用
添加消息动作枚举和类型定义,提高类型安全性
优化内容脚本注入失败时的处理逻辑

* feat(QR码): 添加粘贴图片功能并优化上传组件

添加全局粘贴事件监听,支持从剪贴板直接粘贴二维码图片进行解析。重构上传组件为独立组件QrCodeUploader,包含拖拽上传、预览、进度显示和错误处理功能。优化页面样式和用户体验。

- 在QrCodePage添加粘贴事件监听
- 创建QrCodeUploader组件整合上传功能
- 更新测试用例格式
- 调整多个页面的背景色样式

* feat(表单识别): 新增表单识别页面功能与模板管理

- 添加表单识别页面样式配置
- 实现表单字段扫描与展示功能
- 新增数据模板管理工具类
- 添加数据验证工具类
- 扩展表单识别页面功能,包括操作历史记录
- 支持模板的导入导出功能
- 优化表单填充操作的用户体验

* feat(消息系统): 添加标签页刷新功能

在消息系统中新增 RELOAD_TAB 动作类型和 tabId 字段,用于处理标签页刷新请求
修改 StorageCleanerPage 使用后台脚本发送刷新请求,确保弹窗关闭后仍能执行
在 background.ts 中添加标签页刷新处理逻辑,包括错误处理和响应返回

* refactor(theme): 重构主题颜色和样式配置

- 更新主题颜色以满足 WCAG AA 可访问性标准
- 提取全局样式配置到统一变量
- 使用语义化颜色变量替换硬编码值
- 为输入框样式创建统一配置

* feat: add URL entry management components and QR code generation feature

- Introduced `UrlEntryItem` and `UrlEntryList` components for displaying and managing URL entries.
- Added `UrlToQrCodeSection` component for generating QR codes from URLs with download and copy functionality.
- Implemented `AutoRefreshToggle`, `CleaningResult`, `DomainHeader`, `ErrorDisplay`, `OptionItem`, and `StorageOptionsGrid` components for enhanced user interface in storage cleaning.
- Created custom hooks `useStorageCleaner` and `useStorageState` for managing storage-related states and preferences.
- Added utility hook `useUrlPreferences` for handling URL entry preferences.

* feat: 新增时间戳转换器和相关组件,优化时间戳页面功能

* Refactor message handling and storage cleaning logic

* feat: 添加 GitHub Actions CI/CD 工作流,支持自动化构建与发布
This commit is contained in:
LingandRX
2026-04-23 13:57:55 +08:00
committed by GitHub
parent 0379f96e80
commit 4850c92365
44 changed files with 4168 additions and 2021 deletions
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { Box, Typography, Paper } from '@mui/material';
const FeatureDescription: React.FC = () => {
return (
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<Box sx={{ p: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong></strong>
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong></strong>
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong></strong>
</Typography>
<Typography variant="body2">
<strong></strong>
</Typography>
</Box>
</Paper>
);
};
export default FeatureDescription;
+123
View File
@@ -0,0 +1,123 @@
import React from 'react';
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemText,
ListItemIcon,
Collapse,
Chip,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import InputIcon from '@mui/icons-material/Input';
// 字段数据接口
interface FieldData {
id: string;
fieldType: string;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
}
// 字段类型显示名称映射
const FIELD_TYPE_NAMES: Record<string, string> = {
text: '文本',
email: '邮箱',
phone: '手机号',
number: '数字',
date: '日期',
textarea: '文本域',
radio: '单选框',
checkbox: '复选框',
select: '下拉框',
password: '密码',
name: '姓名',
id_card: '身份证号',
unknown: '未知',
};
// 字段类型颜色映射
const FIELD_TYPE_COLORS: Record<
string,
'default' | 'primary' | 'secondary' | 'error' | 'success' | 'warning'
> = {
email: 'primary',
phone: 'success',
number: 'secondary',
date: 'warning',
password: 'error',
name: 'primary',
id_card: 'secondary',
text: 'default',
textarea: 'default',
unknown: 'default',
};
interface FieldListProps {
fields: FieldData[];
showFields: boolean;
onToggleShowFields: () => void;
}
const FieldList: React.FC<FieldListProps> = ({ fields, showFields, onToggleShowFields }) => {
if (fields.length === 0) return null;
return (
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider',
px: 2,
py: 1.5,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={onToggleShowFields}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
({fields.length})
</Typography>
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
<Collapse in={showFields}>
<List dense sx={{ maxHeight: 300, overflow: 'auto' }}>
{fields.map((field, index) => (
<ListItem key={field.id} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<InputIcon fontSize="small" color="action" />
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{field.label || field.name || field.placeholder || `字段 ${index + 1}`}
</Typography>
<Chip
label={FIELD_TYPE_NAMES[field.fieldType] || '未知'}
size="small"
color={FIELD_TYPE_COLORS[field.fieldType] || 'default'}
variant="outlined"
/>
</Box>
}
secondary={field.placeholder || field.name}
/>
</ListItem>
))}
</List>
</Collapse>
</Paper>
);
};
export default FieldList;
+79
View File
@@ -0,0 +1,79 @@
import React from 'react';
import { Button, Stack, CircularProgress } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import ClearAllIcon from '@mui/icons-material/ClearAll';
import { formRecognizerPageStyles } from '@/config/pageTheme';
interface MainActionsProps {
loading: boolean;
onFillValidData: () => void;
onFillInvalidData: () => void;
onClearAllFields: () => void;
}
const MainActions: React.FC<MainActionsProps> = ({
loading,
onFillValidData,
onFillInvalidData,
onClearAllFields,
}) => {
return (
<Stack spacing={2} sx={{ mb: 4 }}>
<Button
variant="contained"
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={onFillValidData}
disabled={loading}
fullWidth
sx={{
...formRecognizerPageStyles.buttonStyle,
bgcolor: formRecognizerPageStyles.validColor,
'&:hover': {
bgcolor: formRecognizerPageStyles.validDark,
},
}}
>
{loading ? '填充中...' : '一键填充(有效数据)'}
</Button>
<Button
variant="contained"
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <ErrorOutlineIcon />}
onClick={onFillInvalidData}
disabled={loading}
fullWidth
sx={{
...formRecognizerPageStyles.buttonStyle,
bgcolor: formRecognizerPageStyles.invalidColor,
'&:hover': {
bgcolor: formRecognizerPageStyles.invalidDark,
},
}}
>
{loading ? '填充中...' : '一键填充(异常数据)'}
</Button>
<Button
variant="outlined"
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <ClearAllIcon />}
onClick={onClearAllFields}
disabled={loading}
fullWidth
sx={{
...formRecognizerPageStyles.buttonStyle,
borderColor: formRecognizerPageStyles.clearColor,
color: formRecognizerPageStyles.clearColor,
'&:hover': {
borderColor: formRecognizerPageStyles.clearDark,
bgcolor: formRecognizerPageStyles.clearBg,
},
}}
>
{loading ? '清空中...' : '一键清空所有表单'}
</Button>
</Stack>
);
};
export default MainActions;
+80
View File
@@ -0,0 +1,80 @@
import React from 'react';
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemText,
Collapse,
Chip,
Divider,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
interface OperationHistoryItem {
time: string;
type: string;
content: string;
result: string;
}
interface OperationHistoryProps {
history: OperationHistoryItem[];
showHistory: boolean;
onToggleShowHistory: () => void;
}
const OperationHistory: React.FC<OperationHistoryProps> = ({
history,
showHistory,
onToggleShowHistory,
}) => {
if (history.length === 0) return null;
return (
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 4 }}>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider',
px: 2,
py: 1.5,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={onToggleShowHistory}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
({history.length})
</Typography>
{showHistory ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
<Collapse in={showHistory}>
<List dense sx={{ maxHeight: 300, overflow: 'auto' }}>
{history.map((item, index) => (
<Box key={index}>
{index > 0 && <Divider />}
<ListItem>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label={item.type} size="small" color="primary" variant="outlined" />
<Typography variant="body2">{item.content}</Typography>
</Box>
}
secondary={`${item.time} · ${item.result}`}
/>
</ListItem>
</Box>
))}
</List>
</Collapse>
</Paper>
);
};
export default OperationHistory;
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { Box, Typography, Paper, FormControlLabel, Switch } from '@mui/material';
interface OptionsPanelProps {
includeHidden: boolean;
onIncludeHiddenChange: (checked: boolean) => void;
}
const OptionsPanel: React.FC<OptionsPanelProps> = ({ includeHidden, onIncludeHiddenChange }) => {
return (
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 4 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<Box sx={{ p: 2 }}>
<FormControlLabel
control={
<Switch
checked={includeHidden}
onChange={(e) => onIncludeHiddenChange(e.target.checked)}
color="primary"
/>
}
label="包含隐藏字段"
sx={{ width: '100%' }}
/>
</Box>
</Paper>
);
};
export default OptionsPanel;
+324
View File
@@ -0,0 +1,324 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Button,
Stack,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
InputAdornment,
} 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 jsQR from 'jsqr';
import CopyButton from '@/components/CopyButton';
import { qrCodePageStyles } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface QrCodeToUrlSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const QrCodeToUrlSection = ({
expanded,
onExpandedChange,
showMessage,
}: QrCodeToUrlSectionProps) => {
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
const [parsedUrl, setParsedUrl] = useState('');
const [parseError, setParseError] = useState('');
const [parsing, setParsing] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
setQrCodeFile(file);
setParseError('');
setParsedUrl('');
}
};
const parseQrCode = async () => {
if (!qrCodeFile) {
showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
return;
}
try {
setParsing(true);
setParseError('');
setParsedUrl('');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法创建 canvas 上下文');
}
const image = new Image();
image.src = URL.createObjectURL(qrCodeFile);
await new Promise<void>((resolve, reject) => {
image.onload = () => {
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
resolve();
};
image.onerror = () => reject(new Error('图片加载失败'));
});
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
setParsedUrl(code.data);
showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
} else {
showMessage('未检测到二维码', { severity: 'error', autoHideDuration: 1000 });
}
} catch (error) {
console.error('解析二维码失败:', error);
showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
} finally {
setParsing(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 {
setQrCodeFile(file);
setParseError('');
setParsedUrl('');
showMessage('图片粘贴成功', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
showMessage('粘贴图片失败,请重试', { severity: 'error', autoHideDuration: 3000 });
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [expanded, showMessage]);
return (
<Accordion
expanded={expanded}
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<LinkIcon color="success" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
URL
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
border: '2px dashed',
borderColor: qrCodeFile ? qrCodePageStyles.successColor : 'grey.200',
borderRadius: 3,
p: 4,
bgcolor: qrCodeFile ? 'rgba(76, 175, 80, 0.05)' : 'grey.50',
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
borderColor: qrCodePageStyles.successColor,
bgcolor: 'rgba(76, 175, 80, 0.05)',
},
}}
>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
style={{
display: 'none',
}}
id="qr-code-upload"
/>
<label
htmlFor="qr-code-upload"
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
>
{qrCodeFile ? (
<Box sx={{ textAlign: 'center', width: '100%', position: 'relative' }}>
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<img
src={URL.createObjectURL(qrCodeFile)}
alt="QR Code Preview"
style={{
maxWidth: '100%',
maxHeight: 160,
borderRadius: 8,
objectFit: 'contain',
}}
/>
<Button
variant="contained"
size="small"
onClick={(e) => {
e.stopPropagation();
setQrCodeFile(null);
setParsedUrl('');
setParseError('');
showMessage('图片已清除', {
severity: 'success',
autoHideDuration: 1000,
});
}}
sx={{
position: 'absolute',
top: -8,
right: -8,
minWidth: '32px',
width: '32px',
height: '32px',
borderRadius: '50%',
bgcolor: 'rgba(244, 67, 54, 0.9)',
color: 'white',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
transition: 'all 0.2s ease-in-out',
'&:hover': {
bgcolor: 'rgba(211, 47, 47, 0.95)',
transform: 'scale(1.1)',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
},
'&:active': {
transform: 'scale(0.95)',
},
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '16px',
lineHeight: 1,
padding: 0,
}}
>
×
</Button>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{qrCodeFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
) : (
<>
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
</Typography>
<Typography variant="caption" color="text.secondary">
PNGJPGWEBP
</Typography>
</>
)}
</label>
</Box>
<Button
variant="contained"
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
onClick={parseQrCode}
disabled={parsing}
sx={{
py: 1.2,
borderRadius: 3,
bgcolor: qrCodePageStyles.successColor,
fontWeight: 700,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
{parsing ? '解析中...' : '解析二维码'}
</Button>
<Box
sx={{
position: 'relative',
mt: 2,
}}
>
<TextField
label="解析结果"
value={parsedUrl}
fullWidth
variant="outlined"
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<CopyButton
text={parsedUrl}
tooltip="复制"
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;
+415
View File
@@ -0,0 +1,415 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Box,
Typography,
Paper,
CircularProgress,
Alert,
IconButton,
useMediaQuery,
useTheme,
} from '@mui/material';
import ImageIcon from '@mui/icons-material/Image';
import ClearIcon from '@mui/icons-material/Clear';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import jsQR from 'jsqr';
import GlobalSnackbar, { useSnackbar } from './GlobalSnackbar';
import CopyButton from './CopyButton';
interface QrCodeUploaderProps {
onQrCodeDetected?: (data: string) => void;
supportedFormats?: string[];
maxFileSize?: number; // in bytes
timeout?: number; // in milliseconds
showPreview?: boolean;
showProgress?: boolean;
className?: string;
}
const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
onQrCodeDetected,
supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
maxFileSize = 5 * 1024 * 1024, // 5MB
timeout = 10000, // 10 seconds
showPreview = true,
showProgress = true,
className,
}) => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 3000 });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadAreaRef = useRef<HTMLDivElement>(null);
// 清理预览 URL
useEffect(() => {
return () => {
if (preview) {
URL.revokeObjectURL(preview);
}
};
}, [preview]);
// 处理文件
const processFile = useCallback(
async (file: File) => {
setUploading(true);
setProgress(0);
try {
// 模拟上传进度
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 90) {
clearInterval(progressInterval);
return prev;
}
return prev + 10;
});
}, 200);
// 读取文件并解析二维码
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法创建 canvas 上下文');
}
const image = new Image();
image.src = URL.createObjectURL(file);
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('图片加载超时'));
}, timeout);
image.onload = () => {
clearTimeout(timeoutId);
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
resolve();
};
image.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('图片加载失败'));
};
});
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
clearInterval(progressInterval);
setProgress(100);
if (code) {
setResult(code.data);
showMessage('二维码解析成功', { severity: 'success' });
if (onQrCodeDetected) {
onQrCodeDetected(code.data);
}
} else {
setError('未检测到二维码');
showMessage('未检测到二维码', { severity: 'error' });
}
} catch (err) {
setError(err instanceof Error ? err.message : '解析失败');
showMessage('解析失败: ' + (err instanceof Error ? err.message : '未知错误'), {
severity: 'error',
});
} finally {
setUploading(false);
// 延迟清除进度,让用户看到完成状态
setTimeout(() => setProgress(0), 500);
}
},
[timeout, showMessage, onQrCodeDetected],
);
// 处理文件
const handleFile = useCallback(
(selectedFile: File) => {
// 检查文件格式
if (!supportedFormats.includes(selectedFile.type)) {
setError(
`不支持的文件格式。支持的格式: ${supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}`,
);
showMessage('不支持的文件格式', { severity: 'error' });
return;
}
// 检查文件大小
if (selectedFile.size > maxFileSize) {
const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
setError(`文件大小超过限制。最大支持 ${maxSizeMB}MB`);
showMessage(`文件大小超过限制,最大支持 ${maxSizeMB}MB`, { severity: 'error' });
return;
}
// 重置状态
setError(null);
setResult(null);
setFile(selectedFile);
// 创建预览
if (showPreview) {
const previewUrl = URL.createObjectURL(selectedFile);
setPreview(previewUrl);
}
// 开始处理
processFile(selectedFile);
},
[supportedFormats, maxFileSize, showPreview, showMessage, processFile],
);
// 处理文件选择
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
handleFile(selectedFile);
}
};
// 处理拖拽事件
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) {
handleFile(droppedFile);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = (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 pastedFile = items[i].getAsFile();
if (pastedFile) {
handleFile(pastedFile);
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [handleFile]);
// 清除文件
const handleClear = () => {
setFile(null);
setPreview(null);
setResult(null);
setError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box className={className}>
{/* 上传区域 */}
<Paper
ref={uploadAreaRef}
elevation={0}
sx={{
p: isMobile ? 3 : 4,
borderRadius: 4,
border: `2px dashed ${dragging ? 'primary.main' : 'grey.300'}`,
bgcolor: dragging ? 'primary.lighter' : 'grey.50',
transition: 'all 0.2s ease',
textAlign: 'center',
cursor: 'pointer',
position: 'relative',
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept={supportedFormats.join(',')}
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{!file && !uploading ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<ImageIcon sx={{ fontSize: isMobile ? 36 : 48, color: 'grey.400', mb: 2 }} />
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
</Typography>
<Typography variant="caption" color="text.secondary">
{supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB
</Typography>
</Box>
) : file && showPreview && preview ? (
<Box sx={{ position: 'relative' }}>
<img
src={preview}
alt="QR Code Preview"
style={{
maxWidth: '100%',
maxHeight: 200,
borderRadius: 8,
objectFit: 'contain',
}}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
sx={{
position: 'absolute',
top: -8,
right: -8,
bgcolor: 'rgba(244, 67, 54, 0.9)',
color: 'white',
'&:hover': {
bgcolor: 'rgba(211, 47, 47, 0.95)',
},
}}
>
<ClearIcon fontSize="small" />
</IconButton>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{file.name}
</Typography>
</Box>
) : uploading && showProgress ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<CircularProgress size={48} sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
...
</Typography>
{progress > 0 && (
<Box sx={{ width: '80%', mt: 2 }}>
<Box
sx={{
height: 8,
bgcolor: 'grey.200',
borderRadius: 4,
overflow: 'hidden',
}}
>
<Box
sx={{
height: '100%',
bgcolor: 'primary.main',
width: `${progress}%`,
transition: 'width 0.3s ease',
}}
/>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 1, display: 'block' }}
>
{progress}%
</Typography>
</Box>
)}
</Box>
) : null}
</Paper>
{/* 结果展示 */}
{(result || error) && (
<Box sx={{ mt: 3 }}>
{result && (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 4,
border: '1px solid',
borderColor: 'success.light',
bgcolor: 'success.lighter',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<CheckCircleIcon sx={{ color: 'success.main', mt: 0.5 }} />
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
</Typography>
<Box sx={{ position: 'relative' }}>
<Typography
variant="body1"
sx={{
fontFamily: 'monospace',
wordBreak: 'break-all',
pr: 8,
}}
>
{result}
</Typography>
<CopyButton
text={result}
tooltip="复制"
size="small"
color="success"
showMessage={showMessage}
style={{
position: 'absolute',
right: 0,
top: 0,
}}
/>
</Box>
</Box>
</Box>
</Paper>
)}
{error && (
<Alert severity="error" sx={{ borderRadius: 4 }} icon={<ErrorIcon />}>
{error}
</Alert>
)}
</Box>
)}
<GlobalSnackbar {...snackbarProps} />
</Box>
);
};
export default QrCodeUploader;
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemText,
ListItemIcon,
Collapse,
Button,
Stack,
CircularProgress,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import FolderIcon from '@mui/icons-material/Folder';
import DownloadIcon from '@mui/icons-material/Download';
import UploadIcon from '@mui/icons-material/Upload';
import { DataTemplate } from '@/utils/dataTemplate';
interface TemplateManagerProps {
templates: DataTemplate[];
showTemplates: boolean;
templateLoading: boolean;
onToggleShowTemplates: () => void;
onLoadTemplates: () => void;
onExportTemplates: () => void;
onImportTemplates: () => void;
}
const TemplateManager: React.FC<TemplateManagerProps> = ({
templates,
showTemplates,
templateLoading,
onToggleShowTemplates,
onLoadTemplates,
onExportTemplates,
onImportTemplates,
}) => {
const handleToggle = () => {
onToggleShowTemplates();
if (!showTemplates) onLoadTemplates();
};
return (
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 4 }}>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider',
px: 2,
py: 1.5,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={handleToggle}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
({templates.length})
</Typography>
{showTemplates ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
<Collapse in={showTemplates}>
<Box sx={{ p: 2 }}>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button
size="small"
startIcon={<DownloadIcon />}
onClick={onExportTemplates}
variant="outlined"
>
</Button>
<Button
size="small"
startIcon={<UploadIcon />}
onClick={onImportTemplates}
variant="outlined"
>
</Button>
</Stack>
{templateLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} />
</Box>
) : templates.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 2 }}>
</Typography>
) : (
<List dense sx={{ maxHeight: 200, overflow: 'auto' }}>
{templates.map((template) => (
<ListItem key={template.id} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<FolderIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary={template.name}
secondary={`${template.fields.length} 个字段 · ${new Date(
template.updatedAt,
).toLocaleDateString('zh-CN')}`}
/>
</ListItem>
))}
</List>
)}
</Box>
</Collapse>
</Paper>
);
};
export default TemplateManager;
+126
View File
@@ -0,0 +1,126 @@
import { useState } from 'react';
import { Box, TextField, Alert, Stack } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import Button from '@/components/Button';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { openUrlPageStyles } from '@/config/pageTheme';
interface UrlEntryFormProps {
onAddEntry: (entry: OpenUrlEntry) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
const [newName, setNewName] = useState<string>('');
const [newUrl, setNewUrl] = useState<string>('');
const showMixedContentWarning =
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
const isValidUrl = (url: string) => {
if (!url.trim()) return false;
try {
new URL(url);
return true;
} catch {
return false;
}
};
const handleAddEntry = () => {
if (!newName.trim()) {
showMessage('请输入名称', { severity: 'error' });
return;
}
if (!isValidUrl(newUrl)) {
showMessage('请输入有效的 URL', { severity: 'error' });
return;
}
onAddEntry({ name: newName.trim(), url: newUrl.trim() });
setNewName('');
setNewUrl('');
showMessage('添加成功', { severity: 'success' });
};
return (
<Box
sx={{
bgcolor: 'background.paper',
p: 2,
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
mb: 3,
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
}}
>
<Stack spacing={2}>
<TextField
label="环境名称"
placeholder="例如: 本地文档"
value={newName}
onChange={(e) => setNewName(e.target.value)}
fullWidth
variant="outlined"
sx={openUrlPageStyles.INPUT_STYLE}
slotProps={{
inputLabel: {
shrink: true,
},
}}
/>
<TextField
label="目标 URL"
placeholder="例如: http://localhost:8000/docs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
fullWidth
variant="outlined"
sx={openUrlPageStyles.INPUT_STYLE}
slotProps={{
inputLabel: {
shrink: true,
},
}}
/>
{showMixedContentWarning && (
<Alert
severity="warning"
sx={{
borderRadius: 3,
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
}}
>
HTTPS HTTP
</Alert>
)}
<Button
variant="contained"
onClick={handleAddEntry}
disabled={!newName.trim() || !isValidUrl(newUrl)}
fullWidth
startIcon={<AddIcon />}
sx={{
py: 1.2,
borderRadius: 4,
bgcolor: openUrlPageStyles.themeColor,
fontWeight: 800,
boxShadow: 'none',
'&:hover': {
bgcolor: 'rgba(25, 118, 210, 0.85)',
boxShadow: '0 8px 24px rgba(25, 118, 210, 0.2)',
},
}}
>
</Button>
</Stack>
</Box>
);
};
export default UrlEntryForm;
+142
View File
@@ -0,0 +1,142 @@
import { Fragment } from 'react';
import { Box, ListItem, Typography, Stack, Divider, Tooltip, IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { alpha } from '@mui/material/styles';
import { storageUtil } from '@/utils/chromeStorage';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { openUrlPageStyles } from '@/config/pageTheme';
interface UrlEntryItemProps {
entry: OpenUrlEntry;
index: number;
isLast: boolean;
onDelete: (index: number) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryItemProps) => {
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
try {
// 存储目标 URL
await storageUtil.set('openUrl/currentUrl', entry.url);
// 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
const [currentTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
const tabId = currentTab.id;
if (!tabId) {
showMessage('无法获取当前标签页', { severity: 'error' });
return;
}
await chrome.sidePanel.setOptions({
tabId,
path: 'sidepanel.html',
enabled: true,
});
await chrome.sidePanel.open({ windowId: currentTab.windowId });
// 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭
if (window.location.pathname.includes('popup.html')) {
window.close();
}
} catch (error) {
console.error('Failed to open side panel:', error);
showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' });
}
};
const handleOpenInNewTab = (entry: OpenUrlEntry) => {
chrome.tabs.create({ url: entry.url });
window.close();
};
const handleDelete = () => {
onDelete(index);
};
return (
<Fragment>
<ListItem
sx={{
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 2,
transition: 'background-color 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 800, color: 'text.primary' }} noWrap>
{entry.name}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{
fontSize: '0.65rem',
fontWeight: 500,
display: 'block',
mt: 0.2,
fontFamily: 'monospace',
}}
>
{entry.url}
</Typography>
</Box>
<Stack direction="row" spacing={0.5}>
<Tooltip title="在侧边栏预览">
<IconButton
size="small"
onClick={() => handleOpenInSidebar(entry)}
sx={{
color: openUrlPageStyles.themeColor,
bgcolor: alpha(openUrlPageStyles.themeColor, 0.05),
'&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' },
}}
>
<VisibilityIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="新标签页打开">
<IconButton
size="small"
onClick={() => handleOpenInNewTab(entry)}
sx={{
color: 'grey.500',
bgcolor: 'grey.100',
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
}}
>
<OpenInNewIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="删除">
<IconButton
size="small"
onClick={handleDelete}
sx={{
color: 'error.main',
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Stack>
</ListItem>
{!isLast && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
</Fragment>
);
};
export default UrlEntryItem;
+63
View File
@@ -0,0 +1,63 @@
import { Box, List, Typography } from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import UrlEntryItem from './UrlEntryItem';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface UrlEntryListProps {
entries: OpenUrlEntry[];
onDeleteEntry: (index: number) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryList = ({ entries, onDeleteEntry, showMessage }: UrlEntryListProps) => {
if (entries.length === 0) {
return (
<Box
sx={{
textAlign: 'center',
py: 4,
bgcolor: 'grey.50',
borderRadius: 4,
border: '1px dashed',
borderColor: 'grey.200',
}}
>
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
<Typography
variant="caption"
color="text.disabled"
sx={{ display: 'block', fontWeight: 600 }}
>
</Typography>
</Box>
);
}
return (
<List
disablePadding
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
overflow: 'hidden',
}}
>
{entries.map((entry, index) => (
<UrlEntryItem
key={index}
entry={entry}
index={index}
isLast={index === entries.length - 1}
onDelete={onDeleteEntry}
showMessage={showMessage}
/>
))}
</List>
);
};
export default UrlEntryList;
+229
View File
@@ -0,0 +1,229 @@
import { useState } from 'react';
import {
Box,
Typography,
TextField,
Button,
Stack,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
} 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 qrcode from 'qrcode';
import { qrCodePageStyles } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface UrlToQrCodeSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlToQrCodeSection = ({
expanded,
onExpandedChange,
showMessage,
}: UrlToQrCodeSectionProps) => {
const [urlInput, setUrlInput] = useState('');
const [urlError, setUrlError] = useState('');
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [generating, setGenerating] = useState(false);
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrlInput(e.target.value);
setUrlError('');
};
const generateQrCode = async () => {
if (!urlInput) {
setUrlError('请输入 URL');
return;
}
try {
setGenerating(true);
setUrlError('');
let url = urlInput;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const dataUrl = await qrcode.toDataURL(url, {
width: 200,
margin: 2,
color: {
dark: qrCodePageStyles.black,
light: qrCodePageStyles.white,
},
});
setQrCodeDataUrl(dataUrl);
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('生成二维码失败:', error);
showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
} finally {
setGenerating(false);
}
};
const downloadQrCode = () => {
if (!qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
showMessage('二维码下载成功', { 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('二维码已复制到剪贴板', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
showMessage('复制二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
}
};
return (
<Accordion
expanded={expanded}
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<QrCodeIcon color="primary" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
URL
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<TextField
label="输入 URL"
placeholder="https://example.com"
value={urlInput}
onChange={handleUrlInputChange}
fullWidth
variant="outlined"
error={!!urlError}
helperText={urlError}
sx={qrCodePageStyles.INPUT_STYLE}
/>
<Button
variant="contained"
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
onClick={generateQrCode}
disabled={generating}
sx={{
py: 1.2,
borderRadius: 3,
bgcolor: qrCodePageStyles.successColor,
fontWeight: 700,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
{generating ? '生成中...' : '生成二维码'}
</Button>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: 200,
border: '2px dashed',
borderColor: 'grey.200',
borderRadius: 3,
p: 2,
bgcolor: 'grey.50',
}}
>
{qrCodeDataUrl ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
<img
src={qrCodeDataUrl}
alt="QR Code"
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
/>
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={downloadQrCode}
sx={{
borderRadius: 2,
borderColor: qrCodePageStyles.successColor,
color: qrCodePageStyles.successColor,
'&:hover': {
borderColor: qrCodePageStyles.successDark,
bgcolor: 'rgba(76, 175, 80, 0.05)',
},
}}
>
</Button>
<Button
variant="contained"
startIcon={<ContentCopyIcon />}
onClick={copyQrCode}
sx={{
borderRadius: 2,
bgcolor: qrCodePageStyles.successColor,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
</Button>
</Box>
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
</Typography>
)}
</Box>
</Stack>
</AccordionDetails>
</Accordion>
);
};
export default UrlToQrCodeSection;
+6 -2
View File
@@ -42,7 +42,11 @@ describe('Button Component', () => {
it('should not call onClick when disabled', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Disabled Button</Button>);
render(
<Button onClick={handleClick} disabled>
Disabled Button
</Button>,
);
fireEvent.click(screen.getByRole('button', { name: /disabled button/i }));
expect(handleClick).not.toHaveBeenCalled();
@@ -70,4 +74,4 @@ describe('Button Component', () => {
expect(button).toBeDisabled();
});
});
});
});