Merge develop into main (#27)
* feat: 添加 contextMenus 权限和消息类型定义 * feat: 实现右键菜单注册与点击分流逻辑 - 新增 utils/contextMenu.ts 封装菜单配置和解析函数 - 在 background.ts 监听 onInstalled 初始化菜单 - 实现 contextMenus.onClicked 点击事件处理 - 分流逻辑:优先发送到侧边栏,否则打开 options 页面 - 添加 contextMenu 单元测试(15个测试用例) - 更新 vitest.setup.ts 添加 contextMenus mock * feat: 实现 Content Script 原位轻量提示 UI - 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏) - 新增 contextMenuHandler.ts 处理右键菜单消息 - 时间戳转换:在点击位置显示转换结果 - 文本统计:显示字符/单词/行数/字节统计 - 更新 messageHandler.ts 挂载右键菜单消息监听 * feat: 实现 React 页面层右键菜单数据联动 - 新增 useContextMenuData Hook 处理右键菜单数据传递 - 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据 - RouterProvider 支持从 URL 参数解析右键菜单数据 - 添加 contextMenu/pendingData 存储键到 StorageSchema - 添加 useContextMenuData 单元测试(12个测试用例) * test: 添加 background 单元测试与边界情况处理 - 新增 entrypoints/__tests__/background.test.ts (12个测试用例) - 超长文本截断限制 (>10000字符) - Base64 内联图片拦截并返回错误提示 - 更新 parseContextMenuClick 返回 ParseResult 格式 - 更新测试匹配新的返回格式 - lint/typecheck/test 全部通过 (579个测试) * fix: 修复右键菜单跳转到错误页面的问题 - 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html' - WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html * fix: 修复右键菜单跳转目标为 popup 页面 - 将 fallback 页面从 options.html 改为 popup.html - popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面 - options 页面是独立设置页,不支持 URL 参数路由 * fix: 使用 openPopup() 替代 tabs.create() 打开弹窗 - background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗 - 先保存数据到 storage,然后打开 popup - RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面 - 移除之前错误的 tabs.create() 方式 * fix: 修复右键菜单数据未传递到目标页面的问题 - RouterProvider 不再提前清除 pendingData - 让目标页面的 useContextMenuData 来消费和清除数据 - 这样确保 TextStatistics 等页面能正确接收选中的文本 * fix: 修复 popup 已打开时右键菜单不生效的问题 - 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听 - 当 pendingData 变化时,自动跳转到对应功能页面 - 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题 * feat: 支持右键菜单图片二维码识别 - 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码 - 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL - 自动下载图片并解析二维码,显示解析结果 * refactor: 暂时移除图片二维码识别功能 - 删除 qrCode-image 右键菜单配置 - 删除 parseQrCodeFromUrl 函数 - 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码 - 更新相关测试用例 * feat: 支持右键菜单时间戳转换 - 在 useTimestampConverter 中添加 useContextMenuData hook - 智能识别输入是时间戳还是日期时间字符串 - 自动切换到对应模式并执行转换 - 时间戳自动识别秒/毫秒单位 * fix: 修复右键菜单功能的多个逻辑漏洞 1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai 2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据 3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用 4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID * refactor(qrcode): 提取独立组件并添加单元测试 - 创建 pages/QrCode/types.ts 定义状态类型接口 - 提取 QrCodePreview 组件用于二维码预览和操作 - 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑 - 重构 UrlToQrCodeSection 状态提升到父组件 - 重构 QrCodeToUrlSection 通过回调传递解析结果 - 更新主页面 index.tsx 集中管理所有状态 - 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例) * refactor(qrcode): 引入通用组件并重构双栏布局 - 引入 SwitchButtonGroup 用于模式切换 - 引入 TextInputArea 用于文本输入和结果展示 - 使用 MUI Grid 构建响应式双栏布局 - 实现 generate/parse 模式下的左右面板内容切换 - 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor * refactor(qrcode): 国际化补充、质量保障与错误处理优化 - 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等) - 创建 useDebounce Hook 实现输入防抖 (200ms) - 使用 useRef 解决 useEffect 无限循环问题 - 编写 pages/QrCode 单元测试 (7 个用例) - 优化右键菜单错误处理,改进用户提示 - 运行 603 个测试全部通过 * refactor(qrcode): 组件化重构,采用 Context + Hook 模式 - 创建 QrCodeContext 和 QrCodeProvider 管理共享状态 - 提取 useQrCode Hook 封装所有状态和业务逻辑 - 创建 GeneratePanel 组件处理二维码生成模式 - 创建 ParsePanel 组件处理二维码解析模式 - 简化 index.tsx 为容器组件 (340行 → 47行) - 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection) - 清理 types.ts 中未使用的类型定义 - 603 个测试全部通过 * fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动 - 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250 - 与 QRious 生成的二维码大小保持一致 - 解决输入内容变化时预览窗口大小跳动问题 * fix(qrcode): 修复切换 tab 后二维码图片失效问题 - 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑 - 在 useQrCode hook 中统一管理预览 URL 生命周期 - 创建新预览 URL 前释放旧的,避免内存泄漏 * fix(qrcode): 固定图片上传区域高度,避免布局抖动 - 将 DROPZONE 的 minHeight: 200 改为 height: 250 - 与二维码预览图片高度保持一致 - 解决上传图片时 div 高度变化问题 * refactor(qrCode): 优化二维码解析功能 - 将'二维码转 URL'改为'二维码转文本' - 将解析结果的文本预览框的 placeholder 置为空 - 去除手动解析二维码功能,只保留自动解析 * fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式 * feat(pageHeader): popup 模式下隐藏 PageHeader 组件 * fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁 * perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积 - 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效 - 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks) - 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB) - 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB) - PageErrorBoundary chunk 从 454KB 降至 36KB
This commit is contained in:
@@ -1,292 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface QrCodeToUrlSectionProps {
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
/** 桌面端强制展开(隐藏折叠交互) */
|
||||
forceExpanded?: boolean;
|
||||
}
|
||||
|
||||
const QrCodeToUrlSection = ({
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
forceExpanded = false,
|
||||
}: QrCodeToUrlSectionProps) => {
|
||||
const { t } = useTranslation(['qrCode']);
|
||||
const { showMessage } = useSnackbar();
|
||||
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [parsedUrl, setParsedUrl] = useState('');
|
||||
const [parseError, setParseError] = useState('');
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 清理预览 URL,防止内存泄漏
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const handleFileChange = useCallback((file: File) => {
|
||||
setQrCodeFile(file);
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
setParseError('');
|
||||
setParsedUrl('');
|
||||
}, []);
|
||||
|
||||
const handleClearFile = () => {
|
||||
setQrCodeFile(null);
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
setPreviewUrl('');
|
||||
setParsedUrl('');
|
||||
setParseError('');
|
||||
showMessage(t('qrCode:imageCleared'), {
|
||||
severity: 'success',
|
||||
autoHideDuration: 1000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFileChange(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) {
|
||||
handleFileChange(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const parseQrCode = async () => {
|
||||
if (!qrCodeFile) {
|
||||
showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setParsing(true);
|
||||
setParseError('');
|
||||
setParsedUrl('');
|
||||
|
||||
const result = await parseQrCodeFromFile(qrCodeFile);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setParsedUrl(result.data);
|
||||
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} else {
|
||||
showMessage(result.error || t('qrCode:noQrDetected'), {
|
||||
severity: 'error',
|
||||
autoHideDuration: 1000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析二维码失败:', error);
|
||||
showMessage(t('qrCode:parseError'), { 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 {
|
||||
handleFileChange(file);
|
||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('处理粘贴图片失败:', error);
|
||||
showMessage(t('qrCode:imagePasteError'), {
|
||||
severity: 'error',
|
||||
autoHideDuration: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [expanded, showMessage, handleFileChange, t]);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
expanded={forceExpanded ? true : expanded}
|
||||
onChange={forceExpanded ? undefined : (_, isExpanded) => onExpandedChange(isExpanded)}
|
||||
sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={qrCodePageStyles.ACCORDION_SUMMARY}>
|
||||
<Box sx={qrCodePageStyles.ACCORDION_TITLE_ICON}>
|
||||
<LinkIcon color="success" />
|
||||
<Typography variant="subtitle1" sx={qrCodePageStyles.ACCORDION_TITLE_TEXT}>
|
||||
{t('qrCode:qrToUrl')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={3}>
|
||||
<Box
|
||||
className="qr-flex-grow"
|
||||
sx={qrCodePageStyles.DROPZONE(dragging, !!qrCodeFile)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleInputChange}
|
||||
style={{ display: 'none' }}
|
||||
id="qr-code-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="qr-code-upload"
|
||||
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
||||
>
|
||||
{qrCodeFile ? (
|
||||
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_WRAPPER}>
|
||||
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_BOX}>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Code Preview"
|
||||
style={qrCodePageStyles.IMAGE_PREVIEW_IMG}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearFile();
|
||||
}}
|
||||
sx={qrCodePageStyles.CLEAR_BUTTON}
|
||||
>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
{qrCodeFile.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('qrCode:clickToChange')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('qrCode:clickToUpload')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('qrCode:supportFormats')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
|
||||
onClick={parseQrCode}
|
||||
disabled={parsing}
|
||||
sx={qrCodePageStyles.PRIMARY_BUTTON}
|
||||
>
|
||||
{parsing ? t('qrCode:parsing') : t('qrCode:parseButton')}
|
||||
</Button>
|
||||
|
||||
<Box sx={qrCodePageStyles.RESULT_INPUT}>
|
||||
<TextField
|
||||
label={t('qrCode:resultLabel')}
|
||||
value={parsedUrl}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<CopyButton
|
||||
text={parsedUrl}
|
||||
tooltip={t('qrCode:copyTooltip')}
|
||||
size="small"
|
||||
color={qrCodePageStyles.primaryColor}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={qrCodePageStyles.INPUT_STYLE}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{parseError && (
|
||||
<Alert severity="error" sx={{ borderRadius: 3 }}>
|
||||
{parseError}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodeToUrlSection;
|
||||
@@ -1,187 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import QRious from 'qrious';
|
||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface UrlToQrCodeSectionProps {
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
/** 桌面端强制展开(隐藏折叠交互) */
|
||||
forceExpanded?: boolean;
|
||||
}
|
||||
|
||||
const UrlToQrCodeSection = ({
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
forceExpanded = false,
|
||||
}: UrlToQrCodeSectionProps) => {
|
||||
const { t } = useTranslation(['qrCode']);
|
||||
const { showMessage } = useSnackbar();
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [urlError, setUrlError] = useState('');
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrlInput(e.target.value);
|
||||
setUrlError('');
|
||||
};
|
||||
|
||||
const generateQrCode = async () => {
|
||||
if (!urlInput) {
|
||||
setUrlError(t('qrCode:enterUrlError'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGenerating(true);
|
||||
setUrlError('');
|
||||
|
||||
let url = urlInput;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
// 使用 QRious 替代 qrcode 库,体积更小
|
||||
const qr = new QRious({
|
||||
value: url,
|
||||
size: 250,
|
||||
level: 'H',
|
||||
foreground: '#000000',
|
||||
background: '#FFFFFF',
|
||||
});
|
||||
|
||||
setQrCodeDataUrl(qr.toDataURL());
|
||||
showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error);
|
||||
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadQrCode = () => {
|
||||
if (!qrCodeDataUrl) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = qrCodeDataUrl;
|
||||
link.download = 'qrcode.png';
|
||||
link.click();
|
||||
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 300 });
|
||||
};
|
||||
|
||||
const copyQrCode = async () => {
|
||||
if (!qrCodeDataUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(qrCodeDataUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': blob,
|
||||
}),
|
||||
]);
|
||||
|
||||
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('复制二维码失败:', error);
|
||||
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
expanded={forceExpanded ? true : expanded}
|
||||
onChange={forceExpanded ? undefined : (_, isExpanded) => onExpandedChange(isExpanded)}
|
||||
sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={qrCodePageStyles.ACCORDION_SUMMARY}>
|
||||
<Box sx={qrCodePageStyles.ACCORDION_TITLE_ICON}>
|
||||
<QrCodeIcon color="primary" />
|
||||
<Typography variant="subtitle1" sx={qrCodePageStyles.ACCORDION_TITLE_TEXT}>
|
||||
{t('qrCode:urlToQr')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
label={t('qrCode:urlInputLabel')}
|
||||
placeholder={t('qrCode:urlInputPlaceholder')}
|
||||
value={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={qrCodePageStyles.PRIMARY_BUTTON}
|
||||
>
|
||||
{generating ? t('qrCode:generating') : t('qrCode:generateButton')}
|
||||
</Button>
|
||||
|
||||
<Box className="qr-flex-grow" sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
||||
{qrCodeDataUrl ? (
|
||||
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
|
||||
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
|
||||
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={downloadQrCode}
|
||||
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
|
||||
>
|
||||
{t('qrCode:downloadButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={copyQrCode}
|
||||
sx={qrCodePageStyles.COPY_BUTTON}
|
||||
>
|
||||
{t('qrCode:copyQrButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={qrCodePageStyles.PLACEHOLDER_TEXT}
|
||||
>
|
||||
{t('qrCode:qrCodeWillShow')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlToQrCodeSection;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import QrCodePage from '../index';
|
||||
|
||||
// Mock useLazyTranslation
|
||||
vi.mock('@/utils/useLazyTranslation', () => ({
|
||||
useLazyTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn(), language: 'zh-CN' },
|
||||
isLoaded: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock getEntryPointType
|
||||
vi.mock('@/config/features', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config/features')>();
|
||||
return {
|
||||
...actual,
|
||||
getEntryPointType: () => 'sidepanel',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock 子组件
|
||||
vi.mock('@/components/QrCodePreview', () => ({
|
||||
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ImageUploader', () => ({
|
||||
default: () => <div data-testid="image-uploader">ImageUploader</div>,
|
||||
}));
|
||||
|
||||
// Mock QRious
|
||||
vi.mock('qrious', () => ({
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
toDataURL: () => 'data:image/png;base64,mock',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock useSnackbar
|
||||
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||
useSnackbar: () => ({
|
||||
showMessage: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('QrCodePage', () => {
|
||||
it('应该默认渲染生成模式', () => {
|
||||
render(<QrCodePage />);
|
||||
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染模式切换按钮', () => {
|
||||
render(<QrCodePage />);
|
||||
expect(screen.getByText('qrCode:urlToQr')).toBeInTheDocument();
|
||||
expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染页面标题', () => {
|
||||
render(<QrCodePage />);
|
||||
expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument();
|
||||
expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('切换到解析模式应该渲染 ImageUploader', () => {
|
||||
render(<QrCodePage />);
|
||||
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
|
||||
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('切换回生成模式应该渲染 QrCodePreview', () => {
|
||||
render(<QrCodePage />);
|
||||
// 先切换到解析模式
|
||||
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
|
||||
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
|
||||
// 再切换回生成模式
|
||||
fireEvent.click(screen.getByText('qrCode:urlToQr'));
|
||||
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染输入区域', () => {
|
||||
render(<QrCodePage />);
|
||||
expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染双栏布局容器', () => {
|
||||
const { container } = render(<QrCodePage />);
|
||||
const gridContainer = container.querySelector('.MuiGrid-container');
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Grid } from '@mui/material';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import QrCodePreview from '@/components/QrCodePreview';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||
|
||||
export default function GeneratePanel() {
|
||||
const { t } = useLazyTranslation('qrCode');
|
||||
const { showMessage } = useSnackbar();
|
||||
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextInputArea
|
||||
title={t('qrCode:urlInputLabel')}
|
||||
value={generatorState.textToEncode}
|
||||
onChange={setTextToEncode}
|
||||
placeholder={t('qrCode:urlInputPlaceholder')}
|
||||
showCount
|
||||
showClear
|
||||
allowCopy
|
||||
externalError={generatorState.inputError}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl={generatorState.qrCodeDataUrl}
|
||||
onDownload={downloadQrCode}
|
||||
onCopy={copyQrCode}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { Grid } from '@mui/material';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import ImageUploader from '@/components/ImageUploader';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||
|
||||
export default function ParsePanel() {
|
||||
const { t } = useLazyTranslation('qrCode');
|
||||
const { showMessage } = useSnackbar();
|
||||
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
|
||||
|
||||
// 全局粘贴事件监听
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
// 检查是否有图片
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
handleFileChange(file);
|
||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有 Base64 字符串
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text && text.startsWith('data:image/')) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch(text);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
||||
handleFileChange(file);
|
||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('处理 Base64 图片失败:', error);
|
||||
showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 });
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFileChange, showMessage, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('paste', handlePaste);
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [handlePaste]);
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<ImageUploader
|
||||
selectedFile={parserState.selectedFile}
|
||||
onFileChange={handleFileChange}
|
||||
onClearFile={handleClearFile}
|
||||
previewUrl={parserState.previewUrl}
|
||||
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
|
||||
dragging={parserState.dragging}
|
||||
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextInputArea
|
||||
title={t('qrCode:resultLabel')}
|
||||
value={parserState.decodedResult}
|
||||
readOnly
|
||||
showClear={false}
|
||||
allowCopy
|
||||
placeholder=""
|
||||
externalError={parserState.parseError}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from '../types';
|
||||
|
||||
export interface QrCodeContextValue {
|
||||
// 模式
|
||||
mode: QrCodeMode;
|
||||
setMode: (mode: QrCodeMode) => void;
|
||||
|
||||
// 生成器状态
|
||||
generatorState: QrCodeGeneratorState;
|
||||
setTextToEncode: (text: string) => void;
|
||||
generateQrCode: (text: string) => Promise<void>;
|
||||
downloadQrCode: () => void;
|
||||
copyQrCode: () => Promise<void>;
|
||||
|
||||
// 解析器状态
|
||||
parserState: QrCodeParserState;
|
||||
setParserState: React.Dispatch<React.SetStateAction<QrCodeParserState>>;
|
||||
parseQrCode: (file: File) => Promise<void>;
|
||||
handleFileChange: (file: File) => void;
|
||||
handleClearFile: () => void;
|
||||
}
|
||||
|
||||
export const QrCodeContext = createContext<QrCodeContextValue | null>(null);
|
||||
|
||||
export function useQrCodeContext() {
|
||||
const context = useContext(QrCodeContext);
|
||||
if (!context) {
|
||||
throw new Error('useQrCodeContext must be used within QrCodeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import QRious from 'qrious';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import { useDebounce } from '@/utils/useDebounce';
|
||||
import type { QrCodeContextValue } from '../contexts/QrCodeContext';
|
||||
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from '../types';
|
||||
|
||||
export function useQrCode(): QrCodeContextValue {
|
||||
const { t } = useLazyTranslation('qrCode');
|
||||
const { showMessage } = useSnackbar();
|
||||
|
||||
// 当前模式
|
||||
const [mode, setMode] = useState<QrCodeMode>('generate');
|
||||
|
||||
// 二维码生成器状态
|
||||
const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
|
||||
textToEncode: '',
|
||||
qrCodeDataUrl: '',
|
||||
generating: false,
|
||||
inputError: '',
|
||||
});
|
||||
|
||||
// 二维码解析器状态
|
||||
const [parserState, setParserState] = useState<QrCodeParserState>({
|
||||
decodedResult: '',
|
||||
parsing: false,
|
||||
parseError: '',
|
||||
selectedFile: null,
|
||||
previewUrl: '',
|
||||
dragging: false,
|
||||
});
|
||||
|
||||
// 生成二维码
|
||||
const generateQrCode = useCallback(
|
||||
async (text: string) => {
|
||||
if (!text) {
|
||||
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: '' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
|
||||
|
||||
let url = text;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
const qr = new QRious({
|
||||
value: url,
|
||||
size: 250,
|
||||
level: 'H',
|
||||
foreground: '#000000',
|
||||
background: '#FFFFFF',
|
||||
});
|
||||
|
||||
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: qr.toDataURL() }));
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error);
|
||||
setGeneratorState((prev) => ({
|
||||
...prev,
|
||||
inputError: t('qrCode:generateError'),
|
||||
}));
|
||||
showMessage(t('qrCode:generateError'), { severity: 'error', autoHideDuration: 3000 });
|
||||
} finally {
|
||||
setGeneratorState((prev) => ({ ...prev, generating: false }));
|
||||
}
|
||||
},
|
||||
[t, showMessage],
|
||||
);
|
||||
|
||||
// 使用 useRef 存储 generateQrCode 的最新引用,避免无限循环
|
||||
const generateQrCodeRef = useRef(generateQrCode);
|
||||
generateQrCodeRef.current = generateQrCode;
|
||||
|
||||
// 防抖处理输入文本(200ms)
|
||||
const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200);
|
||||
|
||||
// 当防抖后的文本变化时,自动生成二维码
|
||||
useEffect(() => {
|
||||
if (debouncedTextToEncode && mode === 'generate') {
|
||||
generateQrCodeRef.current(debouncedTextToEncode);
|
||||
}
|
||||
}, [debouncedTextToEncode, mode]);
|
||||
|
||||
// 设置输入文本
|
||||
const setTextToEncode = useCallback((text: string) => {
|
||||
setGeneratorState((prev) => ({ ...prev, textToEncode: text }));
|
||||
}, []);
|
||||
|
||||
// 处理右键菜单数据
|
||||
const handleContextMenuData = useCallback((payload: string) => {
|
||||
setMode('generate');
|
||||
setGeneratorState((prev) => ({ ...prev, textToEncode: payload }));
|
||||
}, []);
|
||||
|
||||
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
|
||||
|
||||
// 解析二维码
|
||||
const parseQrCode = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' }));
|
||||
|
||||
const result = await parseQrCodeFromFile(file);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
|
||||
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} else {
|
||||
const errorMsg = result.error || t('qrCode:noQrDetected');
|
||||
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析二维码失败:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
|
||||
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
||||
} finally {
|
||||
setParserState((prev) => ({ ...prev, parsing: false }));
|
||||
}
|
||||
},
|
||||
[t, showMessage],
|
||||
);
|
||||
|
||||
// 下载二维码
|
||||
const downloadQrCode = useCallback(() => {
|
||||
if (!generatorState.qrCodeDataUrl) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = generatorState.qrCodeDataUrl;
|
||||
link.download = 'qrcode.png';
|
||||
link.click();
|
||||
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
}, [generatorState.qrCodeDataUrl, showMessage, t]);
|
||||
|
||||
// 复制二维码
|
||||
const copyQrCode = useCallback(async () => {
|
||||
if (!generatorState.qrCodeDataUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(generatorState.qrCodeDataUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': blob,
|
||||
}),
|
||||
]);
|
||||
|
||||
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('复制二维码失败:', error);
|
||||
showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
|
||||
}
|
||||
}, [generatorState.qrCodeDataUrl, showMessage, t]);
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = useCallback(
|
||||
(file: File) => {
|
||||
setParserState((prev) => {
|
||||
// 释放旧的预览 URL
|
||||
if (prev.previewUrl) {
|
||||
URL.revokeObjectURL(prev.previewUrl);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
selectedFile: file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
decodedResult: '',
|
||||
parseError: '',
|
||||
};
|
||||
});
|
||||
// 自动解析
|
||||
parseQrCode(file);
|
||||
},
|
||||
[parseQrCode],
|
||||
);
|
||||
|
||||
// 清除文件
|
||||
const handleClearFile = useCallback(() => {
|
||||
if (parserState.previewUrl) {
|
||||
URL.revokeObjectURL(parserState.previewUrl);
|
||||
}
|
||||
setParserState((prev) => ({
|
||||
...prev,
|
||||
selectedFile: null,
|
||||
previewUrl: '',
|
||||
decodedResult: '',
|
||||
parseError: '',
|
||||
}));
|
||||
}, [parserState.previewUrl]);
|
||||
|
||||
return {
|
||||
mode,
|
||||
setMode,
|
||||
generatorState,
|
||||
setTextToEncode,
|
||||
generateQrCode,
|
||||
downloadQrCode,
|
||||
copyQrCode,
|
||||
parserState,
|
||||
setParserState,
|
||||
parseQrCode,
|
||||
handleFileChange,
|
||||
handleClearFile,
|
||||
};
|
||||
}
|
||||
+38
-60
@@ -1,74 +1,52 @@
|
||||
import { Box, CircularProgress, Container, Stack, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { Box, Container, useMediaQuery, useTheme } from '@mui/material';
|
||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||
import UrlToQrCodeSection from '@/pages/QrCode/UrlToQrCodeSection';
|
||||
import QrCodeToUrlSection from '@/pages/QrCode/QrCodeToUrlSection';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import { QrCodeContext } from './contexts/QrCodeContext';
|
||||
import { useQrCode } from './hooks/useQrCode';
|
||||
import GeneratePanel from './components/GeneratePanel';
|
||||
import ParsePanel from './components/ParsePanel';
|
||||
import type { QrCodeMode } from './types';
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useLazyTranslation('qrCode');
|
||||
const theme = useTheme();
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up('md'));
|
||||
const qrCode = useQrCode();
|
||||
|
||||
// 使用自定义钩子管理展开状态(移动端使用)
|
||||
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
|
||||
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
|
||||
|
||||
// 初始化未完成时显示加载状态
|
||||
if (!urlInitialized || !qrInitialized) {
|
||||
return (
|
||||
<Container sx={qrCodePageStyles.LOADING_CONTAINER}>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = isDesktop ? (
|
||||
<>
|
||||
<Box sx={qrCodePageStyles.GRID_CELL}>
|
||||
<UrlToQrCodeSection
|
||||
expanded={urlExpanded}
|
||||
onExpandedChange={setUrlExpanded}
|
||||
forceExpanded={isDesktop}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={qrCodePageStyles.GRID_CELL}>
|
||||
<QrCodeToUrlSection
|
||||
expanded={qrExpanded}
|
||||
onExpandedChange={setQrExpanded}
|
||||
forceExpanded={isDesktop}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UrlToQrCodeSection expanded={urlExpanded} onExpandedChange={setUrlExpanded} />
|
||||
<QrCodeToUrlSection expanded={qrExpanded} onExpandedChange={setQrExpanded} />
|
||||
</>
|
||||
);
|
||||
// 模式选项
|
||||
const modeOptions = [
|
||||
{ value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') },
|
||||
{ value: 'parse' as QrCodeMode, label: t('qrCode:qrToUrl') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Container
|
||||
maxWidth={isDesktop ? 'lg' : false}
|
||||
sx={{ py: 2, maxWidth: isDesktop ? undefined : 400 }}
|
||||
>
|
||||
<PageHeader
|
||||
title={t('qrCode:pageTitle')}
|
||||
subtitle={t('qrCode:pageSubtitle')}
|
||||
icon={<QrCodeIcon />}
|
||||
iconColor={qrCodePageStyles.primaryColor}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
<QrCodeContext.Provider value={qrCode}>
|
||||
<Box>
|
||||
<Container
|
||||
maxWidth={isDesktop ? 'lg' : false}
|
||||
sx={{ py: 2, maxWidth: isDesktop ? undefined : 400 }}
|
||||
>
|
||||
<PageHeader
|
||||
title={t('qrCode:pageTitle')}
|
||||
subtitle={t('qrCode:pageSubtitle')}
|
||||
icon={<QrCodeIcon />}
|
||||
iconColor={qrCodePageStyles.primaryColor}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
|
||||
{isDesktop ? (
|
||||
<Box sx={qrCodePageStyles.LAYOUT_GRID}>{sections}</Box>
|
||||
) : (
|
||||
<Stack spacing={3}>{sections}</Stack>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
<SwitchButtonGroup
|
||||
value={qrCode.mode}
|
||||
options={modeOptions}
|
||||
onChange={qrCode.setMode}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{qrCode.mode === 'generate' ? <GeneratePanel /> : <ParsePanel />}
|
||||
</Container>
|
||||
</Box>
|
||||
</QrCodeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 二维码工具页面的状态类型定义
|
||||
*/
|
||||
|
||||
/** 二维码生成模式 */
|
||||
export type QrCodeMode = 'generate' | 'parse';
|
||||
|
||||
/** 二维码生成器的状态 */
|
||||
export interface QrCodeGeneratorState {
|
||||
/** 输入文本(URL 或任意文本) */
|
||||
textToEncode: string;
|
||||
/** 生成的二维码 Data URL */
|
||||
qrCodeDataUrl: string;
|
||||
/** 是否正在生成 */
|
||||
generating: boolean;
|
||||
/** 输入错误信息 */
|
||||
inputError: string;
|
||||
}
|
||||
|
||||
/** 二维码解析器的状态 */
|
||||
export interface QrCodeParserState {
|
||||
/** 解析结果文本 */
|
||||
decodedResult: string;
|
||||
/** 是否正在解析 */
|
||||
parsing: boolean;
|
||||
/** 解析错误信息 */
|
||||
parseError: string;
|
||||
/** 当前选中的文件 */
|
||||
selectedFile: File | null;
|
||||
/** 文件预览 URL */
|
||||
previewUrl: string;
|
||||
/** 是否正在拖拽 */
|
||||
dragging: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user