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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
支持 PNG、JPG、WEBP 格式
|
||||
</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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user